@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BananaSplit contributors
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 ADDED
@@ -0,0 +1,188 @@
1
+ # BananaSplit CLI
2
+
3
+ Standalone access to the BananaSplit API for people and shell-enabled agents.
4
+
5
+ ## Install
6
+
7
+ macOS and Linux (no Bun or Node required):
8
+
9
+ ```sh
10
+ curl -fsSL https://github.com/teotti/banana-cli/releases/latest/download/install.sh | sh
11
+ ```
12
+
13
+ Windows PowerShell (no Bun or Node required):
14
+
15
+ ```powershell
16
+ irm https://github.com/teotti/banana-cli/releases/latest/download/install.ps1 | iex
17
+ ```
18
+
19
+ Or install the TypeScript package with [Bun](https://bun.sh):
20
+
21
+ ```sh
22
+ bun install -g @bananasplitapp/cli
23
+ ```
24
+
25
+ Set `BANANA_INSTALL_DIR` to choose a different destination. To install a
26
+ specific standalone release instead of the latest:
27
+
28
+ ```sh
29
+ curl -fsSL https://github.com/teotti/banana-cli/releases/latest/download/install.sh \
30
+ | BANANA_VERSION=v0.1.0 sh
31
+ ```
32
+
33
+ ```powershell
34
+ $env:BANANA_VERSION = "v0.1.0"
35
+ irm https://github.com/teotti/banana-cli/releases/latest/download/install.ps1 | iex
36
+ ```
37
+
38
+ Release downloads are verified against the published SHA-256 checksums before
39
+ an existing installation is replaced.
40
+
41
+ ## Setup
42
+
43
+ Set your BananaSplit session token:
44
+
45
+ ```sh
46
+ export BANANASPLIT_TOKEN="your-bearer-session-token"
47
+ # Optional; defaults to production:
48
+ export BANANASPLIT_API_URL="http://localhost:8080"
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ Run `banana` from any directory:
54
+
55
+ ```sh
56
+ banana me
57
+ banana balance
58
+ banana balances
59
+ banana currencies
60
+ banana friends
61
+ banana groups
62
+ banana groups members GROUP_ID
63
+ banana groups activities GROUP_ID --search dinner
64
+ banana expenses list
65
+ banana expenses get EXPENSE_ID
66
+ banana payments get PAYMENT_ID
67
+ ```
68
+
69
+ List expenses across the authenticated account, sorted by date or amount:
70
+
71
+ ```sh
72
+ banana expenses list --sort amount --direction desc --limit 10
73
+ banana expenses list --recurring --sort date --direction asc
74
+ banana expenses list --no-recurring --json
75
+ ```
76
+
77
+ Omit both recurring flags to include all expenses. In the interactive browser,
78
+ reaching the last item loads and appends the next page with the same sorting,
79
+ filtering, and page size. Outside the browser, use `--cursor CURSOR` with the
80
+ same options to retrieve the next page. JSON output includes `items`, `hasMore`, and
81
+ `nextCursor`. Each item includes `share`, the current user's share amount,
82
+ and `isRecurring`. Full splits are available through expense details.
83
+
84
+ Create groups, expenses, and payments with explicit API IDs:
85
+
86
+ ```sh
87
+ banana groups create --name "Lisbon trip" --currency-id CURRENCY_ID \
88
+ --type travel --member USER_ID
89
+
90
+ banana expenses add --title Dinner --amount 42 --currency-id CURRENCY_ID \
91
+ --paid-by-id USER_ID --date 2026-09-01 --group-id GROUP_ID \
92
+ --split-type custom --split USER_ID=22 --split FRIEND_ID=20
93
+
94
+ banana payments add --amount 20 --currency-id CURRENCY_ID \
95
+ --from-user-id USER_ID --to-user-id FRIEND_ID --date 2026-09-01 \
96
+ --description "Settle up"
97
+ ```
98
+
99
+ Edit an expense in place. Only the fields you pass change; everything else
100
+ keeps its current value:
101
+
102
+ ```sh
103
+ banana expenses edit EXPENSE_ID --title "Chinese dinner"
104
+ banana expenses edit EXPENSE_ID --group-id GROUP_ID
105
+ banana expenses edit EXPENSE_ID --amount 60 --split USER_ID=30 --split FRIEND_ID=30
106
+ banana expenses edit EXPENSE_ID '{"description":null}'
107
+ ```
108
+
109
+ Moving an expense into a group with `--group-id` clears its direct friendship
110
+ link; `--no-group` does the reverse. Changing `--amount` on an equal split
111
+ redistributes the splits automatically; any other split type needs matching
112
+ `--split` values.
113
+
114
+ Each create command also accepts its API body as one quoted JSON object:
115
+
116
+ ```sh
117
+ banana groups create '{"name":"Lisbon trip","currencyId":"CURRENCY_ID"}'
118
+ banana expenses add '{"title":"Dinner","amount":"42","currencyId":"CURRENCY_ID","paidById":"USER_ID","date":"2026-09-01","splits":[{"userId":"USER_ID","amount":"42"}]}'
119
+ banana payments add '{"amount":"20","currencyId":"CURRENCY_ID","fromUserId":"USER_ID","toUserId":"FRIEND_ID","date":"2026-09-01"}'
120
+ ```
121
+
122
+ Direct expenses require at least one `--split USER_ID=AMOUNT`. Group expenses
123
+ may omit `--split` to use the group's configured default split.
124
+
125
+ Commands print a clean, human-readable summary by default. Use `--json` for
126
+ the same operational fields as compact JSON, or `--raw` for the complete API
127
+ response:
128
+
129
+ ```sh
130
+ banana me
131
+ banana --json groups list --limit 10
132
+ banana groups get GROUP_ID --raw
133
+ ```
134
+
135
+ The output flag may appear before or after the command. `--json` and `--raw`
136
+ cannot be used together.
137
+
138
+ In an interactive terminal, `balance users`, `friends list`, `groups list`, `expenses list`,
139
+ `groups members`, and `groups activities` open searchable browsers; type to
140
+ filter, use the arrow keys to select an item, press Enter to open its details,
141
+ Esc to return, and `q` to quit. In a non-interactive command card, friend and
142
+ group lists fall back to five items and print a copyable `Next page` cursor
143
+ command. Expense lists also default to five items outside the browser and show
144
+ the next cursor. In the browser they use the API's default page size and load
145
+ more as you reach the end. Search filters all loaded expenses; press ↓ at the
146
+ end (or when no items match) to load another page. If loading fails, press ↓
147
+ to retry. Use `--limit N` to choose a page size.
148
+
149
+ ## Development
150
+
151
+ Install Bun, clone this repository, then install dependencies and expose
152
+ `banana` on your path:
153
+
154
+ ```sh
155
+ bun install
156
+ bun link
157
+ banana --help
158
+ ```
159
+
160
+ Run the checks with:
161
+
162
+ ```sh
163
+ bun test
164
+ bun run typecheck
165
+ ```
166
+
167
+ ## Releasing
168
+
169
+ The repository must be public and the `@bananasplitapp` npm organization must
170
+ exist before the first release. Store a temporary granular npm publishing
171
+ token with bypass 2FA as the `NPM_TOKEN` repository secret, update the version
172
+ in `package.json`, then push its matching stable tag:
173
+
174
+ ```sh
175
+ git tag v0.1.0
176
+ git push origin v0.1.0
177
+ ```
178
+
179
+ The tag workflow tests all targets, publishes the GitHub release, and publishes
180
+ the npm package. After the first npm release, configure `release.yml` as the
181
+ package's trusted GitHub Actions publisher and remove `NPM_TOKEN`; subsequent
182
+ publishes use OIDC automatically.
183
+
184
+ Configuration, network, API, and usage errors are written to stderr. Human
185
+ commands print `Error: MESSAGE`, `--json` prints a structured error object, and
186
+ `--raw` prints the API error body when available.
187
+ Session tokens currently expire after 90 days; interactive login and stored
188
+ credentials are not included in this first version.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@bananasplitapp/cli",
3
+ "description": "Command-line access to the BananaSplit API",
4
+ "type": "module",
5
+ "version": "0.1.0",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/teotti/banana-cli.git"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "bin": {
15
+ "banana": "./src/index.ts"
16
+ },
17
+ "engines": {
18
+ "bun": ">=1.4.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "banana": "bun run ./src/index.ts",
25
+ "test": "bun test",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.4.0",
30
+ "typescript": "^7.0.2"
31
+ }
32
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { asArray, asRecord, display } from "./shared";
5
+ import {
6
+ CliFailure,
7
+ type BrowserDetailLoader,
8
+ type BrowserPageLoader,
9
+ type BrowserPresentation,
10
+ type Presentation,
11
+ } from "./types";
12
+
13
+ const PAGER_PROMPT = "↑/↓ navigate · q quit";
14
+ const ANSI = {
15
+ bold: "\x1b[1m",
16
+ yellow: "\x1b[38;2;255;228;0m",
17
+ dim: "\x1b[2m",
18
+ green: "\x1b[32m",
19
+ reset: "\x1b[0m",
20
+ };
21
+
22
+ type BrowserDetailState =
23
+ | { status: "loading"; title: string }
24
+ | { status: "loaded"; title: string; value: string }
25
+ | { status: "error"; title: string; message: string };
26
+
27
+ export function hasInteractivePager() {
28
+ if (process.env.CI || spawnSync("less", ["--version"]).status !== 0) {
29
+ return false;
30
+ }
31
+ try {
32
+ const terminal = openSync("/dev/tty", "r+");
33
+ closeSync(terminal);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export async function pageWithLess(value: string) {
41
+ let terminal: number;
42
+ try {
43
+ terminal = openSync("/dev/tty", "r+");
44
+ } catch {
45
+ return false;
46
+ }
47
+
48
+ try {
49
+ const pager = spawn("less", ["-X", "-Q", `-Ps${PAGER_PROMPT}`], {
50
+ stdio: ["pipe", terminal, terminal],
51
+ });
52
+ const exited = new Promise<boolean>((resolve) => {
53
+ pager.once("error", () => resolve(false));
54
+ pager.once("close", (code) => resolve(code === 0));
55
+ });
56
+ if (!pager.stdin) {
57
+ pager.kill();
58
+ return false;
59
+ }
60
+ pager.stdin.end(`${value}\n`);
61
+ return await exited;
62
+ } finally {
63
+ closeSync(terminal);
64
+ }
65
+ }
66
+
67
+ export function isListPresentation(presentation: Presentation) {
68
+ return (
69
+ presentation === "balance-users" ||
70
+ presentation === "currency-list" ||
71
+ presentation === "expense-list" ||
72
+ presentation === "friend-list" ||
73
+ presentation === "group-list" ||
74
+ presentation === "members" ||
75
+ presentation === "activities"
76
+ );
77
+ }
78
+
79
+ export function isBrowserPresentation(
80
+ presentation: Presentation,
81
+ ): presentation is BrowserPresentation {
82
+ return (
83
+ presentation === "expense-list" ||
84
+ presentation === "balance-users" ||
85
+ presentation === "friend-list" ||
86
+ presentation === "group-list" ||
87
+ presentation === "members" ||
88
+ presentation === "activities"
89
+ );
90
+ }
91
+
92
+ function browserItems(presentation: BrowserPresentation, body: unknown) {
93
+ return asArray(
94
+ presentation === "group-list" ||
95
+ presentation === "friend-list" ||
96
+ presentation === "expense-list"
97
+ ? asRecord(body).items
98
+ : body,
99
+ ).map(asRecord);
100
+ }
101
+
102
+ function filterBrowserItems(
103
+ presentation: BrowserPresentation,
104
+ body: unknown,
105
+ query: string,
106
+ ) {
107
+ const normalizedQuery = query.trim().toLowerCase();
108
+ return browserItems(presentation, body).filter((item) =>
109
+ JSON.stringify(item).toLowerCase().includes(normalizedQuery),
110
+ );
111
+ }
112
+
113
+ function browserItemTitle(
114
+ presentation: BrowserPresentation,
115
+ item: Record<string, unknown>,
116
+ ) {
117
+ if (presentation === "balance-users" || presentation === "friend-list") {
118
+ return display(asRecord(item.user).name);
119
+ }
120
+ if (presentation === "group-list" || presentation === "members") {
121
+ return display(item.name);
122
+ }
123
+ return item.entity === "payment"
124
+ ? `Payment: ${display(item.description)}`
125
+ : `Expense: ${display(item.title)}`;
126
+ }
127
+
128
+ export function renderCollectionBrowser(
129
+ presentation: BrowserPresentation,
130
+ body: unknown,
131
+ query = "",
132
+ selectedIndex = 0,
133
+ detail?: BrowserDetailState,
134
+ rows = Infinity,
135
+ pageStatus?: string,
136
+ ) {
137
+ if (detail) {
138
+ return [
139
+ `${ANSI.bold}${ANSI.yellow}BANANA${ANSI.reset}`,
140
+ "",
141
+ `◆ ${ANSI.bold}${detail.title}${ANSI.reset}`,
142
+ ` ${ANSI.dim}esc/← back · q quit${ANSI.reset}`,
143
+ "",
144
+ detail.status === "loading"
145
+ ? `${ANSI.dim}Loading details…${ANSI.reset}`
146
+ : detail.status === "error"
147
+ ? `Unable to load details: ${detail.message}`
148
+ : detail.value,
149
+ ].join("\n");
150
+ }
151
+
152
+ const response = asRecord(body);
153
+ const normalizedQuery = query.trim().toLowerCase();
154
+ const allItems = browserItems(presentation, body);
155
+ const items = filterBrowserItems(presentation, body, query);
156
+ const activeIndex = Math.min(selectedIndex, Math.max(items.length - 1, 0));
157
+ const visibleCount = Math.max(1, rows - 13);
158
+ const start = Math.max(0, activeIndex - visibleCount + 1);
159
+ const label = {
160
+ "balance-users": "user balances",
161
+ "friend-list": "friends",
162
+ "group-list": "groups",
163
+ "expense-list": "expenses",
164
+ members: "group members",
165
+ activities: "group activities",
166
+ }[presentation];
167
+ const count = `${ANSI.green}${items.length}${
168
+ normalizedQuery ? `/${allItems.length}` : ""
169
+ }${ANSI.reset}`;
170
+ const lines = [
171
+ `${ANSI.bold}${ANSI.yellow}BANANA${ANSI.reset}`,
172
+ "",
173
+ `┌─ ${ANSI.yellow} ${label} ${ANSI.reset}`,
174
+ "│",
175
+ `◇ Found ${count} ${label}`,
176
+ "│",
177
+ `◆ ${ANSI.bold}Browse ${label}${ANSI.reset}`,
178
+ ` Search: ${query}${ANSI.yellow}█${ANSI.reset}`,
179
+ ` ${ANSI.dim}↑↓ move · type search · enter/→ details · esc/← clear · q quit${ANSI.reset}`,
180
+ "",
181
+ ...(items.length
182
+ ? items.slice(start, start + visibleCount).map(
183
+ (item, index) =>
184
+ `${start + index === activeIndex ? `${ANSI.yellow}› ●${ANSI.reset}` : " ○"} ${browserItemTitle(presentation, item)}`,
185
+ )
186
+ : [` ${ANSI.dim}No matching ${label}.${ANSI.reset}`]),
187
+ ];
188
+
189
+ if (pageStatus) {
190
+ lines.push("", pageStatus);
191
+ } else if (
192
+ (presentation === "group-list" ||
193
+ presentation === "friend-list" ||
194
+ presentation === "expense-list") &&
195
+ response.hasMore
196
+ ) {
197
+ lines.push(
198
+ "",
199
+ `${ANSI.dim}${presentation === "expense-list"
200
+ ? "Reach the last item to load more expenses (↓ to continue)."
201
+ : `More ${label} are available from the API.`}${ANSI.reset}`,
202
+ );
203
+ }
204
+ return lines.join("\n");
205
+ }
206
+
207
+ export function renderGroupBrowser(
208
+ body: unknown,
209
+ query = "",
210
+ selectedIndex = 0,
211
+ ) {
212
+ return renderCollectionBrowser("group-list", body, query, selectedIndex);
213
+ }
214
+
215
+ export function hasInteractiveBrowser() {
216
+ return Boolean(
217
+ !process.env.CI &&
218
+ process.stdin.isTTY &&
219
+ process.stdout.isTTY &&
220
+ typeof process.stdin.setRawMode === "function",
221
+ );
222
+ }
223
+
224
+ export async function browseCollection(
225
+ presentation: BrowserPresentation,
226
+ body: unknown,
227
+ loadDetail: BrowserDetailLoader,
228
+ loadPage?: BrowserPageLoader,
229
+ ) {
230
+ if (!hasInteractiveBrowser()) return false;
231
+
232
+ const input = process.stdin;
233
+ const output = process.stdout;
234
+ const wasRaw = input.isRaw;
235
+ let query = "";
236
+ let selectedIndex = 0;
237
+ let detail: BrowserDetailState | undefined;
238
+ let detailGeneration = 0;
239
+ let finished = false;
240
+ let loadingPage = false;
241
+ let pageStatus: string | undefined;
242
+
243
+ emitKeypressEvents(input);
244
+ input.setRawMode(true);
245
+ input.resume();
246
+ output.write("\x1b[?1049h\x1b[?25l");
247
+
248
+ return await new Promise<boolean>((resolve) => {
249
+ const draw = () => {
250
+ if (finished) return;
251
+ output.write(
252
+ `\x1b[H\x1b[2J${renderCollectionBrowser(presentation, body, query, selectedIndex, detail, output.rows, pageStatus)}`,
253
+ );
254
+ };
255
+ const finish = () => {
256
+ finished = true;
257
+ detailGeneration++;
258
+ input.off("keypress", onKeypress);
259
+ process.off("SIGWINCH", draw);
260
+ input.setRawMode(wasRaw);
261
+ input.pause();
262
+ output.write("\x1b[?25h\x1b[?1049l");
263
+ resolve(true);
264
+ };
265
+ const openDetail = async (item: Record<string, unknown>) => {
266
+ const generation = ++detailGeneration;
267
+ const title = browserItemTitle(presentation, item);
268
+ detail = { status: "loading", title };
269
+ draw();
270
+ try {
271
+ const value = await loadDetail(item);
272
+ if (finished || generation !== detailGeneration) return;
273
+ detail = { status: "loaded", title, value };
274
+ } catch (error) {
275
+ if (finished || generation !== detailGeneration) return;
276
+ detail = {
277
+ status: "error",
278
+ title,
279
+ message:
280
+ error instanceof CliFailure ? error.message : "Unexpected error",
281
+ };
282
+ }
283
+ draw();
284
+ };
285
+ const loadNextPage = async () => {
286
+ const response = asRecord(body);
287
+ const cursor = response.nextCursor;
288
+ if (finished || loadingPage || !loadPage || !response.hasMore ||
289
+ typeof cursor !== "string" || !cursor) return;
290
+ loadingPage = true;
291
+ pageStatus = "Loading more expenses…";
292
+ draw();
293
+ try {
294
+ const page = asRecord(await loadPage(cursor));
295
+ if (finished) return;
296
+ if (page.hasMore && page.nextCursor === cursor) {
297
+ throw new CliFailure("api", "Expense pagination did not advance");
298
+ }
299
+ body = { ...page, items: [...asArray(response.items), ...asArray(page.items)] };
300
+ pageStatus = undefined;
301
+ } catch (error) {
302
+ if (finished) return;
303
+ pageStatus = `Unable to load more expenses: ${error instanceof CliFailure
304
+ ? error.message : "Unexpected error"}. Press ↓ to retry.`;
305
+ } finally {
306
+ loadingPage = false;
307
+ }
308
+ draw();
309
+ };
310
+ const onKeypress = (
311
+ text: string,
312
+ key: { ctrl?: boolean; meta?: boolean; name?: string },
313
+ ) => {
314
+ const itemCount = filterBrowserItems(presentation, body, query).length;
315
+ if ((key.ctrl && key.name === "c") || key.name === "q") {
316
+ finish();
317
+ return;
318
+ }
319
+ if (detail) {
320
+ if (key.name === "escape" || key.name === "left") {
321
+ detailGeneration++;
322
+ detail = undefined;
323
+ draw();
324
+ }
325
+ return;
326
+ }
327
+ if (key.name === "escape" || key.name === "left") {
328
+ query = "";
329
+ selectedIndex = 0;
330
+ } else if (key.name === "up") {
331
+ selectedIndex = Math.max(0, selectedIndex - 1);
332
+ } else if (key.name === "down") {
333
+ selectedIndex = Math.min(Math.max(itemCount - 1, 0), selectedIndex + 1);
334
+ if (selectedIndex >= itemCount - 1) void loadNextPage();
335
+ } else if (key.name === "backspace" || key.name === "delete") {
336
+ query = query.slice(0, -1);
337
+ selectedIndex = 0;
338
+ } else if (key.name === "return" || key.name === "enter" || key.name === "right") {
339
+ const items = filterBrowserItems(presentation, body, query);
340
+ const selected = items[Math.min(selectedIndex, items.length - 1)];
341
+ if (selected) void openDetail(selected);
342
+ return;
343
+ } else if (
344
+ text &&
345
+ !key.ctrl &&
346
+ !key.meta &&
347
+ text >= " " &&
348
+ text !== "\x7f"
349
+ ) {
350
+ query += text;
351
+ selectedIndex = 0;
352
+ }
353
+ draw();
354
+ };
355
+
356
+ input.on("keypress", onKeypress);
357
+ process.on("SIGWINCH", draw);
358
+ draw();
359
+ });
360
+ }