@bananasplitapp/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.
@@ -0,0 +1,543 @@
1
+ import {
2
+ DEFAULT_LIST_LIMIT,
3
+ appendQuery,
4
+ asArray,
5
+ asRecord,
6
+ cleanUserSummary,
7
+ display,
8
+ encodedDetailId,
9
+ enumValue,
10
+ formatCard,
11
+ humanAmount,
12
+ isoDate,
13
+ namedEntity,
14
+ numeric,
15
+ parseJsonBody,
16
+ parseOptions,
17
+ positiveInteger,
18
+ repeatedStrings,
19
+ requiredString,
20
+ requirePositionals,
21
+ wantsHelp,
22
+ yesNo,
23
+ } from "../shared";
24
+ import { CliFailure, type ParsedCommand, type Presenter } from "../types";
25
+
26
+ const HELP = `Usage: banana expenses list [--sort date|amount] [--direction asc|desc]
27
+ [--limit N] [--cursor CURSOR]
28
+ [--recurring | --no-recurring]
29
+ or: banana expenses add JSON
30
+ or: banana expenses add --title TEXT --amount AMOUNT
31
+ --currency-id ID --paid-by-id ID
32
+ --date YYYY-MM-DD|DD-MM-YYYY
33
+ [--group-id ID] [--description TEXT]
34
+ [--split-type equal|custom|percentage|shares]
35
+ [--split USER_ID=AMOUNT]...
36
+ or: banana expenses get <expense-id>
37
+ or: banana expenses edit <expense-id> JSON
38
+ or: banana expenses edit <expense-id> [--title TEXT] [--amount AMOUNT]
39
+ [--currency-id ID] [--paid-by-id ID] [--date DATE]
40
+ [--description TEXT] [--group-id ID | --no-group]
41
+ [--split-type equal|custom|percentage|shares]
42
+ [--split USER_ID=AMOUNT]...`;
43
+ const GET_HELP = "Usage: banana expenses get <expense-id>";
44
+ const LIST_HELP = `Usage: banana expenses list [options]
45
+
46
+ Options:
47
+ --sort date|amount
48
+ --direction asc|desc
49
+ --limit N (default: ${DEFAULT_LIST_LIMIT}; API default in browser)
50
+ --cursor CURSOR
51
+ --recurring Only recurring expenses
52
+ --no-recurring Only non-recurring expenses
53
+
54
+ Omit both recurring flags to include all expenses.
55
+ In the browser, reaching the last item loads the next page automatically.
56
+ Reuse the same options when requesting the next cursor.`;
57
+ const EDIT_HELP = `Usage: banana expenses edit <expense-id> JSON
58
+ or: banana expenses edit <expense-id> [--title TEXT] [--amount AMOUNT]
59
+ [--currency-id ID] [--paid-by-id ID] [--date DATE]
60
+ [--description TEXT] [--group-id ID | --no-group]
61
+ [--split-type equal|custom|percentage|shares]
62
+ [--split USER_ID=AMOUNT]...
63
+
64
+ Only the fields you pass change; everything else keeps its current value.`;
65
+
66
+ function parseSplits(value: unknown) {
67
+ return repeatedStrings(value).map((split) => {
68
+ const separator = split.indexOf("=");
69
+ if (separator < 1 || separator === split.length - 1) {
70
+ throw new CliFailure(
71
+ "usage",
72
+ `--split must use USER_ID=AMOUNT\n${HELP}`,
73
+ );
74
+ }
75
+ return {
76
+ userId: split.slice(0, separator),
77
+ amount: split.slice(separator + 1),
78
+ };
79
+ });
80
+ }
81
+
82
+ export function parseExpenses(args: string[]): ParsedCommand {
83
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
84
+ return { kind: "help", text: HELP };
85
+ }
86
+ const [command, ...rest] = args;
87
+ if (command === "list") return parseExpensesList(rest);
88
+ if (command === "get") {
89
+ if (wantsHelp(rest)) return { kind: "help", text: GET_HELP };
90
+ const { positionals } = parseOptions(rest);
91
+ requirePositionals(positionals, 1, GET_HELP);
92
+ return {
93
+ kind: "request",
94
+ path: `/expenses/${encodeURIComponent(positionals[0])}`,
95
+ presentation: "expense",
96
+ };
97
+ }
98
+ if (command === "edit") return parseExpensesEdit(rest);
99
+ if (command !== "add") throw new CliFailure("usage", HELP);
100
+ if (wantsHelp(rest)) return { kind: "help", text: HELP };
101
+
102
+ const { positionals, values } = parseOptions(rest, {
103
+ amount: { type: "string" },
104
+ "currency-id": { type: "string" },
105
+ date: { type: "string" },
106
+ description: { type: "string" },
107
+ "group-id": { type: "string" },
108
+ "paid-by-id": { type: "string" },
109
+ split: { type: "string", multiple: true },
110
+ "split-type": { type: "string" },
111
+ title: { type: "string" },
112
+ });
113
+ const jsonBody = parseJsonBody(positionals, values, HELP);
114
+ if (jsonBody !== undefined) {
115
+ return {
116
+ kind: "request",
117
+ method: "POST",
118
+ path: "/expenses",
119
+ presentation: "expense-created",
120
+ body: jsonBody,
121
+ };
122
+ }
123
+ requirePositionals(positionals, 0, HELP);
124
+
125
+ const groupId = values["group-id"] as string | undefined;
126
+ const description = values.description as string | undefined;
127
+ const splits = parseSplits(values.split);
128
+ const splitType = enumValue(values["split-type"], "--split-type", [
129
+ "equal",
130
+ "custom",
131
+ "percentage",
132
+ "shares",
133
+ ] as const);
134
+ if (splits.length === 0 && groupId === undefined) {
135
+ throw new CliFailure(
136
+ "usage",
137
+ `At least one --split is required unless --group-id is provided\n${HELP}`,
138
+ );
139
+ }
140
+ if (splits.length === 0 && splitType !== undefined) {
141
+ throw new CliFailure(
142
+ "usage",
143
+ `At least one --split is required with --split-type\n${HELP}`,
144
+ );
145
+ }
146
+
147
+ return {
148
+ kind: "request",
149
+ method: "POST",
150
+ path: "/expenses",
151
+ presentation: "expense-created",
152
+ body: {
153
+ title: requiredString(values.title, "--title", HELP),
154
+ amount: requiredString(values.amount, "--amount", HELP),
155
+ currencyId: requiredString(values["currency-id"], "--currency-id", HELP),
156
+ paidById: requiredString(values["paid-by-id"], "--paid-by-id", HELP),
157
+ date: isoDate(values.date, HELP),
158
+ splits,
159
+ ...(groupId === undefined ? {} : { groupId }),
160
+ ...(description === undefined ? {} : { description }),
161
+ ...(splitType === undefined ? {} : { splitType }),
162
+ },
163
+ };
164
+ }
165
+
166
+ function parseExpensesList(args: string[]): ParsedCommand {
167
+ if (wantsHelp(args)) return { kind: "help", text: LIST_HELP };
168
+ const { positionals, values } = parseOptions(args, {
169
+ cursor: { type: "string" },
170
+ direction: { type: "string" },
171
+ limit: { type: "string" },
172
+ "no-recurring": { type: "boolean" },
173
+ recurring: { type: "boolean" },
174
+ sort: { type: "string" },
175
+ });
176
+ requirePositionals(positionals, 0, LIST_HELP);
177
+ if (values.recurring && values["no-recurring"]) {
178
+ throw new CliFailure(
179
+ "usage",
180
+ `--recurring and --no-recurring cannot be used together\n${LIST_HELP}`,
181
+ );
182
+ }
183
+
184
+ const query = new URLSearchParams();
185
+ appendQuery(
186
+ query,
187
+ "l",
188
+ positiveInteger(values.limit, "--limit") ?? String(DEFAULT_LIST_LIMIT),
189
+ );
190
+ appendQuery(query, "cursor", values.cursor as string | undefined);
191
+ appendQuery(
192
+ query,
193
+ "sort",
194
+ enumValue(values.sort, "--sort", ["date", "amount"] as const),
195
+ );
196
+ appendQuery(
197
+ query,
198
+ "direction",
199
+ enumValue(values.direction, "--direction", ["asc", "desc"] as const),
200
+ );
201
+ appendQuery(
202
+ query,
203
+ "recurring",
204
+ values["no-recurring"] ? false : (values.recurring as boolean | undefined),
205
+ );
206
+ return {
207
+ kind: "request",
208
+ path: "/expenses",
209
+ presentation: "expense-list",
210
+ query,
211
+ };
212
+ }
213
+
214
+ function parseExpensesEdit(args: string[]): ParsedCommand {
215
+ if (wantsHelp(args)) return { kind: "help", text: EDIT_HELP };
216
+ const { positionals, values } = parseOptions(args, {
217
+ amount: { type: "string" },
218
+ "currency-id": { type: "string" },
219
+ date: { type: "string" },
220
+ description: { type: "string" },
221
+ "group-id": { type: "string" },
222
+ "no-group": { type: "boolean" },
223
+ "paid-by-id": { type: "string" },
224
+ split: { type: "string", multiple: true },
225
+ "split-type": { type: "string" },
226
+ title: { type: "string" },
227
+ });
228
+ if (positionals.length === 0) {
229
+ throw new CliFailure("usage", EDIT_HELP);
230
+ }
231
+ const [id, ...rest] = positionals;
232
+ const path = `/expenses/${encodeURIComponent(id)}`;
233
+
234
+ const jsonBody = parseJsonBody(rest, values, EDIT_HELP);
235
+ if (jsonBody !== undefined) {
236
+ return {
237
+ kind: "request",
238
+ method: "PUT",
239
+ path,
240
+ presentation: "expense-updated",
241
+ mergeExpense: path,
242
+ body: jsonBody,
243
+ };
244
+ }
245
+ requirePositionals(positionals, 1, EDIT_HELP);
246
+
247
+ const groupId = values["group-id"] as string | undefined;
248
+ if (groupId !== undefined && values["no-group"] === true) {
249
+ throw new CliFailure(
250
+ "usage",
251
+ `--group-id and --no-group cannot be used together\n${EDIT_HELP}`,
252
+ );
253
+ }
254
+ const splits = parseSplits(values.split);
255
+ const splitType = enumValue(values["split-type"], "--split-type", [
256
+ "equal",
257
+ "custom",
258
+ "percentage",
259
+ "shares",
260
+ ] as const);
261
+ const patch: Record<string, unknown> = {
262
+ ...(values.title === undefined ? {} : { title: values.title }),
263
+ ...(values.amount === undefined ? {} : { amount: values.amount }),
264
+ ...(values["currency-id"] === undefined
265
+ ? {}
266
+ : { currencyId: values["currency-id"] }),
267
+ ...(values["paid-by-id"] === undefined
268
+ ? {}
269
+ : { paidById: values["paid-by-id"] }),
270
+ ...(values.date === undefined
271
+ ? {}
272
+ : { date: isoDate(values.date, EDIT_HELP) }),
273
+ ...(values.description === undefined
274
+ ? {}
275
+ : { description: values.description }),
276
+ ...(groupId === undefined ? {} : { groupId }),
277
+ ...(values["no-group"] === true ? { groupId: null } : {}),
278
+ ...(splitType === undefined ? {} : { splitType }),
279
+ ...(splits.length === 0 ? {} : { splits }),
280
+ };
281
+ if (Object.keys(patch).length === 0) {
282
+ throw new CliFailure(
283
+ "usage",
284
+ `At least one field to change is required\n${EDIT_HELP}`,
285
+ );
286
+ }
287
+
288
+ return {
289
+ kind: "request",
290
+ method: "PUT",
291
+ path,
292
+ presentation: "expense-updated",
293
+ mergeExpense: path,
294
+ body: patch,
295
+ };
296
+ }
297
+
298
+ function cleanCreatedExpense(body: unknown) {
299
+ const expense = asRecord(body);
300
+ return {
301
+ id: expense.id ?? null,
302
+ title: expense.title ?? null,
303
+ description: expense.description ?? null,
304
+ amount: numeric(expense.amount),
305
+ currencyId: expense.currencyId ?? null,
306
+ paidById: expense.paidById ?? null,
307
+ groupId: expense.groupId ?? null,
308
+ date: expense.date ?? null,
309
+ timezone: expense.timezone ?? null,
310
+ splitType: expense.splitType ?? null,
311
+ splits: asArray(expense.shares).map((value) => {
312
+ const share = asRecord(value);
313
+ return {
314
+ userId: share.userId ?? null,
315
+ amount: numeric(share.amount),
316
+ };
317
+ }),
318
+ };
319
+ }
320
+
321
+ function splitEvenly(total: string, userIds: string[]) {
322
+ const cents = Math.round(Number(total) * 100);
323
+ if (!Number.isFinite(cents) || userIds.length === 0) return undefined;
324
+ const base = Math.floor(cents / userIds.length);
325
+ let remainder = cents - base * userIds.length;
326
+ return userIds.map((userId) => {
327
+ const extra = remainder > 0 ? 1 : 0;
328
+ remainder -= extra;
329
+ return { userId, amount: ((base + extra) / 100).toFixed(2) };
330
+ });
331
+ }
332
+
333
+ export function mergeExpenseBody(current: unknown, patch: Record<string, unknown>) {
334
+ const expense = asRecord(current);
335
+ const shares = asArray(expense.shares).map((value) => {
336
+ const share = asRecord(value);
337
+ return {
338
+ userId: String(share.userId ?? ""),
339
+ amount: String(share.amount ?? ""),
340
+ };
341
+ });
342
+ const merged: Record<string, unknown> = {
343
+ title: expense.title,
344
+ description: expense.description ?? null,
345
+ amount: String(expense.amount ?? ""),
346
+ currencyId: expense.currencyId,
347
+ paidById: expense.paidById,
348
+ groupId: expense.groupId ?? null,
349
+ friendshipId: expense.friendshipId ?? null,
350
+ date: expense.date,
351
+ timezone: expense.timezone ?? "UTC",
352
+ splitType: expense.splitType,
353
+ categoryId: expense.categoryId ?? null,
354
+ splits: shares,
355
+ ...patch,
356
+ };
357
+
358
+ // A group expense and a direct (friendship) expense are mutually exclusive.
359
+ if ("groupId" in patch) {
360
+ if (patch.groupId === null) {
361
+ merged.groupId = null;
362
+ } else {
363
+ merged.friendshipId = null;
364
+ }
365
+ }
366
+
367
+ const amountChanged =
368
+ "amount" in patch && String(patch.amount) !== String(expense.amount);
369
+ if (amountChanged && !("splits" in patch)) {
370
+ const evenly =
371
+ merged.splitType === "equal"
372
+ ? splitEvenly(
373
+ String(merged.amount),
374
+ shares.map((share) => share.userId),
375
+ )
376
+ : undefined;
377
+ if (evenly === undefined) {
378
+ throw new CliFailure(
379
+ "usage",
380
+ `Changing --amount on a ${display(merged.splitType)} split needs matching --split values\n${EDIT_HELP}`,
381
+ );
382
+ }
383
+ merged.splits = evenly;
384
+ }
385
+ return merged;
386
+ }
387
+
388
+ function cleanExpense(body: unknown) {
389
+ const expense = asRecord(body);
390
+ return {
391
+ id: expense.id ?? null,
392
+ title: expense.title ?? null,
393
+ description: expense.description ?? null,
394
+ amount: numeric(expense.amount),
395
+ currency: asRecord(expense.currency).code ?? expense.currencyId ?? null,
396
+ paidBy: cleanUserSummary(expense.paidByUser),
397
+ group: expense.groupId
398
+ ? {
399
+ id: expense.groupId,
400
+ name: asRecord(expense.group).name ?? null,
401
+ }
402
+ : null,
403
+ category: asRecord(expense.category).name ?? null,
404
+ date: expense.date ?? null,
405
+ splitType: expense.splitType ?? null,
406
+ createdAt: expense.createdAt ?? null,
407
+ splits: asArray(expense.shares).map((value) => {
408
+ const share = asRecord(value);
409
+ return {
410
+ user: cleanUserSummary(share.user),
411
+ amount: numeric(share.amount),
412
+ };
413
+ }),
414
+ };
415
+ }
416
+
417
+ function formatExpense(body: unknown, title: string) {
418
+ const response = asRecord(body);
419
+ const splits = asArray(response.splits);
420
+ const group = asRecord(response.group);
421
+ return [
422
+ title,
423
+ `Title: ${display(response.title)}`,
424
+ `Amount: ${humanAmount(response.amount, response.currency)}`,
425
+ `Paid by: ${namedEntity(response.paidBy)}`,
426
+ `Group: ${response.group ? namedEntity(group) : "—"}`,
427
+ `Category: ${display(response.category)}`,
428
+ `Description: ${display(response.description)}`,
429
+ `Date: ${display(response.date)}`,
430
+ `Split type: ${display(response.splitType)}`,
431
+ `ID: ${display(response.id)}`,
432
+ "",
433
+ "Splits",
434
+ ...(splits.length
435
+ ? splits.map((value) => {
436
+ const split = asRecord(value);
437
+ return `${namedEntity(split.user)}: ${humanAmount(split.amount, response.currency)}`;
438
+ })
439
+ : ["—"]),
440
+ ].join("\n");
441
+ }
442
+
443
+ export const expensePresenters = {
444
+ "expense-list": {
445
+ clean(body) {
446
+ const response = asRecord(body);
447
+ return {
448
+ items: asArray(response.items).map((value) => {
449
+ const expense = asRecord(value);
450
+ const { splits, ...summary } = cleanExpense(expense);
451
+ return {
452
+ ...summary,
453
+ isRecurring:
454
+ expense.recurringExpenseRuleId != null || expense.recurrence != null,
455
+ share: numeric(asRecord(expense.share).amount),
456
+ };
457
+ }),
458
+ hasMore: response.hasMore === true,
459
+ nextCursor: response.nextCursor ?? null,
460
+ };
461
+ },
462
+ format(body) {
463
+ const response = asRecord(body);
464
+ const items = asArray(response.items);
465
+ const lines = items.length
466
+ ? [
467
+ "Expenses",
468
+ ...items.map((value, index) => {
469
+ const expense = asRecord(value);
470
+ return formatCard(index, expense.title, [
471
+ `ID: ${display(expense.id)}`,
472
+ `Amount: ${humanAmount(expense.amount, expense.currency)}`,
473
+ `Your share: ${humanAmount(expense.share, expense.currency)}`,
474
+ `Paid by: ${namedEntity(expense.paidBy)}`,
475
+ `Group: ${expense.group ? namedEntity(expense.group) : "—"}`,
476
+ `Category: ${display(expense.category)}`,
477
+ `Date: ${display(expense.date)}`,
478
+ `Recurring: ${yesNo(expense.isRecurring)}`,
479
+ ]);
480
+ }),
481
+ ]
482
+ : ["No expenses."];
483
+ lines.push(
484
+ response.hasMore
485
+ ? [
486
+ "More expenses available.",
487
+ ...(response.nextCursor
488
+ ? [
489
+ `Next cursor: ${JSON.stringify(response.nextCursor)}`,
490
+ "Run banana expenses list with --cursor and the same options.",
491
+ ]
492
+ : []),
493
+ ].join("\n")
494
+ : "End of expenses.",
495
+ );
496
+ return lines.join("\n\n");
497
+ },
498
+ browser: {
499
+ detailPath(_command, item) {
500
+ return `/expenses/${encodedDetailId(item.id)}`;
501
+ },
502
+ formatDetail(_item, body) {
503
+ return formatExpense(cleanExpense(body), "Expense");
504
+ },
505
+ },
506
+ },
507
+ expense: {
508
+ clean: cleanExpense,
509
+ format: (body) => formatExpense(body, "Expense"),
510
+ },
511
+ "expense-updated": {
512
+ clean: cleanExpense,
513
+ format: (body) => formatExpense(body, "Expense updated"),
514
+ },
515
+ "expense-created": {
516
+ clean: cleanCreatedExpense,
517
+ format(body) {
518
+ const response = asRecord(body);
519
+ const splits = asArray(response.splits);
520
+ return [
521
+ "Expense created",
522
+ `Title: ${display(response.title)}`,
523
+ `Amount: ${humanAmount(response.amount, response.currencyId)}`,
524
+ `Paid by: ${display(response.paidById)}`,
525
+ `Group ID: ${display(response.groupId)}`,
526
+ `Date: ${display(response.date)}`,
527
+ `Split type: ${display(response.splitType)}`,
528
+ `ID: ${display(response.id)}`,
529
+ "",
530
+ "Splits",
531
+ ...(splits.length
532
+ ? splits.map((value) => {
533
+ const split = asRecord(value);
534
+ return `${display(split.userId)}: ${humanAmount(split.amount, response.currencyId)}`;
535
+ })
536
+ : ["—"]),
537
+ ].join("\n");
538
+ },
539
+ },
540
+ } satisfies Record<
541
+ "expense-list" | "expense" | "expense-updated" | "expense-created",
542
+ Presenter
543
+ >;
@@ -0,0 +1,156 @@
1
+ import {
2
+ DEFAULT_LIST_LIMIT,
3
+ appendQuery,
4
+ asArray,
5
+ asRecord,
6
+ cleanUserSummary,
7
+ currencyCode,
8
+ display,
9
+ encodedDetailId,
10
+ enumValue,
11
+ formatCard,
12
+ humanAmount,
13
+ numeric,
14
+ parseOptions,
15
+ positiveInteger,
16
+ requirePositionals,
17
+ wantsHelp,
18
+ yesNo,
19
+ } from "../shared";
20
+ import { CliFailure, type ParsedCommand, type Presenter } from "../types";
21
+
22
+ const HELP = `Usage: banana friends <command>
23
+
24
+ Commands:
25
+ list [--limit N] [--cursor CURSOR] [--sort balance|lastActivity]
26
+ [--filter all|guests]`;
27
+ const LIST_HELP = `Usage: banana friends list [options]
28
+
29
+ Options:
30
+ --limit N (default: ${DEFAULT_LIST_LIMIT})
31
+ --cursor CURSOR
32
+ --sort balance|lastActivity
33
+ --filter all|guests`;
34
+
35
+ function parseFriendsList(args: string[]): ParsedCommand {
36
+ if (wantsHelp(args)) return { kind: "help", text: LIST_HELP };
37
+ const { positionals, values } = parseOptions(args, {
38
+ cursor: { type: "string" },
39
+ filter: { type: "string" },
40
+ limit: { type: "string" },
41
+ sort: { type: "string" },
42
+ });
43
+ requirePositionals(positionals, 0, LIST_HELP);
44
+
45
+ const query = new URLSearchParams();
46
+ appendQuery(
47
+ query,
48
+ "l",
49
+ positiveInteger(values.limit, "--limit") ?? String(DEFAULT_LIST_LIMIT),
50
+ );
51
+ appendQuery(query, "cursor", values.cursor as string | undefined);
52
+ appendQuery(
53
+ query,
54
+ "sort",
55
+ enumValue(values.sort, "--sort", ["balance", "lastActivity"] as const),
56
+ );
57
+ appendQuery(
58
+ query,
59
+ "filter",
60
+ enumValue(values.filter, "--filter", ["all", "guests"] as const),
61
+ );
62
+
63
+ return {
64
+ kind: "request",
65
+ path: "/friends",
66
+ presentation: "friend-list",
67
+ query,
68
+ };
69
+ }
70
+
71
+ export function parseFriends(args: string[]): ParsedCommand {
72
+ if (args.length === 0) return parseFriendsList(args);
73
+ if (args[0] === "--help" || args[0] === "-h") {
74
+ return { kind: "help", text: HELP };
75
+ }
76
+ const [command, ...rest] = args;
77
+ if (command === "list") return parseFriendsList(rest);
78
+ throw new CliFailure("usage", HELP);
79
+ }
80
+
81
+ function cleanFriendList(body: unknown) {
82
+ const response = asRecord(body);
83
+ return {
84
+ items: asArray(response.items).map((value) => {
85
+ const friendship = asRecord(value);
86
+ const user = asRecord(friendship.user);
87
+ return {
88
+ id: friendship.id ?? null,
89
+ user: cleanUserSummary(user),
90
+ balance: numeric(friendship.balance),
91
+ currency: currencyCode(friendship.currency),
92
+ isGuest: user.isGuest === true,
93
+ isGold: user.isGold === true,
94
+ mostRecentActivity: friendship.mostRecentActivity ?? null,
95
+ };
96
+ }),
97
+ hasMore: response.hasMore === true,
98
+ nextCursor: response.nextCursor ?? null,
99
+ };
100
+ }
101
+
102
+ export const friendPresenters = {
103
+ "friend-list": {
104
+ clean: cleanFriendList,
105
+ format(body) {
106
+ const response = asRecord(body);
107
+ const items = asArray(response.items);
108
+ const lines = items.length
109
+ ? [
110
+ "Friends",
111
+ ...items.map((value, index) => {
112
+ const friendship = asRecord(value);
113
+ const user = asRecord(friendship.user);
114
+ return formatCard(index, user.name, [
115
+ `Friendship ID: ${display(friendship.id)}`,
116
+ `User ID: ${display(user.id)}`,
117
+ `Balance: ${humanAmount(friendship.balance, friendship.currency)}`,
118
+ `Guest: ${yesNo(friendship.isGuest)}`,
119
+ `Gold: ${yesNo(friendship.isGold)}`,
120
+ `Last activity: ${display(friendship.mostRecentActivity)}`,
121
+ ]);
122
+ }),
123
+ ]
124
+ : ["No friends."];
125
+ lines.push(
126
+ response.hasMore && response.nextCursor
127
+ ? [
128
+ "More friends available.",
129
+ `Next page: banana friends list --cursor ${JSON.stringify(response.nextCursor)}`,
130
+ ].join("\n")
131
+ : "End of friends.",
132
+ );
133
+ return lines.join("\n\n");
134
+ },
135
+ browser: {
136
+ detailPath(_command, item) {
137
+ return `/friends/${encodedDetailId(item.id)}`;
138
+ },
139
+ formatDetail(item, body) {
140
+ const friendship = asRecord(body);
141
+ const user = asRecord(friendship.user);
142
+ return [
143
+ `Name: ${display(user.name)}`,
144
+ `Balance: ${humanAmount(item.balance, item.currency)}`,
145
+ `Guest: ${yesNo(user.isGuest)}`,
146
+ `Gold: ${yesNo(user.isGold)}`,
147
+ `Status: ${display(friendship.status)}`,
148
+ `Accepted: ${display(friendship.acceptedAt)}`,
149
+ `Last activity: ${display(item.mostRecentActivity)}`,
150
+ `User ID: ${display(user.id)}`,
151
+ `Friendship ID: ${display(friendship.id ?? item.id)}`,
152
+ ].join("\n");
153
+ },
154
+ },
155
+ },
156
+ } satisfies Record<"friend-list", Presenter>;