@profullstack/coinpay 0.11.2 → 0.12.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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.0] - 2026-10-04
9
+
10
+ ### Added
11
+ - `coinpay finances statements banks|login|fetch|assist|coverage|runs|local|retry`:
12
+ download the original PDF statements behind every linked account from the
13
+ banks themselves (SimpleFIN has none) and import them into the statement
14
+ library with their account and period. Bank sessions stay in local Chrome
15
+ profiles; `fetch` exits 3 when a bank needs signing in again.
16
+ - `coinpay mcp`: a stdio MCP server with finance and statement tools,
17
+ including `statements_fetch`.
18
+ - SDK: `reportStatementFetchRun`, `listStatementFetchRuns`, `getStatementCoverage`.
19
+
8
20
  ## [0.11.2] - 2026-09-16
9
21
 
10
22
  ### Added
package/README.md CHANGED
@@ -758,6 +758,32 @@ const snapshot = await collectFinanceSnapshot(client, { days: 30 });
758
758
  console.log(snapshot.earnings.netUsd, snapshot.bank.liabilities, snapshot.invoices.totals.overdue);
759
759
  ```
760
760
 
761
+ #### Statements from the banks
762
+
763
+ SimpleFIN carries transactions, not the PDF statements. The CLI downloads
764
+ them from each bank on your machine and files them in the statement library:
765
+
766
+ ```bash
767
+ coinpay finances statements login chase # once per bank, in a window; no password is stored
768
+ coinpay finances statements fetch # every new statement, imported with its account and period
769
+ coinpay finances statements coverage # which account-months are still missing
770
+ ```
771
+
772
+ Each bank's session lives in a local Chrome profile under
773
+ `~/.coinpay/statements`. `fetch` exits 3 when a bank needs signing in again.
774
+ Needs Node 22+ and Chrome or Chromium. See
775
+ [docs/STATEMENTS.md](https://github.com/profullstack/coinpayportal/blob/master/docs/STATEMENTS.md).
776
+
777
+ ### MCP
778
+
779
+ ```bash
780
+ claude mcp add coinpay -- coinpay mcp
781
+ ```
782
+
783
+ `coinpay mcp` is a stdio MCP server using your `coinpay login` session. Tools:
784
+ `finance_accounts`, `statements_list`, `statements_coverage`,
785
+ `statements_fetch_runs`, `statements_banks` and `statements_fetch`.
786
+
761
787
  ---
762
788
 
763
789
  ## Webhook Integration
package/bin/coinpay.js CHANGED
@@ -164,6 +164,7 @@ const BOOLEAN_FLAGS = new Set([
164
164
  // finances reports / statements
165
165
  'wait',
166
166
  'strict',
167
+ 'headed',
167
168
  'setup-token-stdin',
168
169
  'overwrite',
169
170
  'no-pending',
@@ -483,6 +484,13 @@ ${colors.cyan}Commands:${colors.reset}
483
484
  statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
484
485
  statements list|get|download|reconcile|delete
485
486
  Statement library; reconcile takes --report --opening --closing --currency
487
+ statements banks Linked banks: signed in for statement downloads? last fetch?
488
+ statements login <bank> Sign in once in a window (no password is stored; Chrome profile on this machine)
489
+ statements fetch [bank…] Download every new PDF statement from the banks and import it
490
+ (--since YYYY-MM, --max N, --headed, --render S; exits 3 if a bank needs you)
491
+ statements assist <bank> A window: every PDF you download is imported
492
+ statements coverage Which account-months have a statement (--months 12)
493
+ statements runs|local|retry Fetch history, the local archive, re-import what failed
486
494
  books [queue] Transactions awaiting category review (--status, --scope, --search)
487
495
  books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
488
496
  books confirm-all Accept every pending suggestion (--yes)
@@ -534,6 +542,8 @@ ${colors.cyan}Commands:${colors.reset}
534
542
 
535
543
  ${colors.bright}self${colors.reset}
536
544
  update Update the coinpay CLI to the latest version
545
+ mcp Serve CoinPay finances to an AI agent over MCP (stdio):
546
+ claude mcp add coinpay -- coinpay mcp
537
547
  upgrade Alias for update
538
548
  remove Uninstall the coinpay CLI
539
549
  uninstall Alias for remove
@@ -4566,6 +4576,20 @@ async function handleLightning(subcommand, args, flags) {
4566
4576
  await handleFinances(subcommand, args, flags);
4567
4577
  break;
4568
4578
 
4579
+ case 'mcp': {
4580
+ // stdout is the protocol channel: nothing else may print to it.
4581
+ const { serveStdio } = await import('../src/mcp.js');
4582
+ await serveStdio({
4583
+ version: VERSION,
4584
+ getClient: async () => {
4585
+ const token = process.env.COINPAY_SESSION_TOKEN || loadConfig().jwtToken;
4586
+ if (!token) throw new Error('Not logged in: run `coinpay login` (finance tools need the merchant session)');
4587
+ return new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 });
4588
+ },
4589
+ });
4590
+ break;
4591
+ }
4592
+
4569
4593
  case 'tui':
4570
4594
  case 'dashboard':
4571
4595
  await handleFinances('tui', [subcommand, ...args].filter(Boolean), flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -123,6 +123,131 @@ function describeReport(report) {
123
123
  return parts.filter(Boolean).join(' · ');
124
124
  }
125
125
 
126
+ const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry']);
127
+
128
+ function ago(iso) {
129
+ if (!iso) return 'never';
130
+ const hours = (Date.now() - Date.parse(iso)) / 3_600_000;
131
+ if (hours < 1) return 'just now';
132
+ return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
133
+ }
134
+
135
+ /**
136
+ * Downloading statements from the banks themselves (SimpleFIN has no
137
+ * documents). Runs on this machine: each bank's session lives in a local
138
+ * Chrome profile under ~/.coinpay/statements and never reaches CoinPay.
139
+ * `fetch` exits 3 when any bank needs signing in again or showed nothing.
140
+ */
141
+ async function runStatementFetchAction(action, rest, flags, { client, out, emit, progress }) {
142
+ const sf = await import('./statements-fetch.js');
143
+ const home = sf.statementsHome();
144
+ const chrome = typeof flags.chrome === 'string' ? flags.chrome : sf.findChrome();
145
+ const months = (value, fallback) => {
146
+ if (value === undefined) return fallback;
147
+ const n = Number(value);
148
+ if (!Number.isInteger(n) || n < 1 || n > 36) throw new CliExit(EXIT.INVALID, '--months is a whole number from 1 to 36');
149
+ return n;
150
+ };
151
+
152
+ try {
153
+ if (action === 'coverage') {
154
+ const data = await api.getStatementCoverage(client, { months: months(flags.months, 12) });
155
+ const mark = { have: '■', missing: '·', open: '○' };
156
+ const lines = data.accounts.map((a) => `${a.months.map((m) => mark[m.state]).join('')} ${a.institutionKey} ${a.accountName}${a.missing ? ` (${a.missing} missing)` : ''}`);
157
+ const fetchers = (data.fetchers || []).map((r) => `${r.institutionKey}: ${r.status} ${ago(r.finishedAt)}${r.message ? ` (${r.message})` : ''}`);
158
+ emit(data, [`${data.months[0]} … ${data.months[data.months.length - 1]} ■ have · missing ○ this month`, ...lines, '', `${data.missing} account-months missing`, ...(fetchers.length ? ['', 'Last fetch per bank:', ...fetchers] : ['', 'No statement fetch has run yet: coinpay finances statements banks'])].join('\n'));
159
+ return EXIT.OK;
160
+ }
161
+ if (action === 'runs') {
162
+ const data = await api.listStatementFetchRuns(client, { limit: flags.limit ? Number(flags.limit) : undefined });
163
+ emit(data, data.runs.length ? data.runs.map((r) => `${r.finishedAt} ${r.institutionKey} ${r.status} ${r.filed} imported, ${r.duplicates} had, ${r.unmatched} unmatched${r.message ? ` ${r.message}` : ''}`).join('\n') : 'No statement fetch has run yet.');
164
+ return EXIT.OK;
165
+ }
166
+ if (action === 'local') {
167
+ const state = sf.loadLocal(home);
168
+ emit({ home, entries: state.entries }, state.entries.length
169
+ ? state.entries.map((e) => `${e.statementId ? 'imported ' : 'local '} ${e.path}${e.importError ? ` (${e.importError})` : ''}`).join('\n')
170
+ : `Nothing downloaded yet (${home})`);
171
+ return EXIT.OK;
172
+ }
173
+ if (action === 'retry') {
174
+ const result = await sf.retryImports({ api: await sf.clientApi(client), home, log: progress });
175
+ emit(result, `${result.imported} imported, ${result.skipped} skipped (no account or period), ${result.failed} failed`);
176
+ return result.failed ? EXIT.FAILURE : EXIT.OK;
177
+ }
178
+
179
+ const bound = await sf.clientApi(client);
180
+ const institutions = sf.groupInstitutions(await bound.listAccounts());
181
+ if (action === 'banks') {
182
+ const state = sf.loadLocal(home);
183
+ let latest = [];
184
+ try {
185
+ latest = (await api.listStatementFetchRuns(client, { limit: 200 })).latest || [];
186
+ } catch {
187
+ // An older deployment without the route still lists the banks.
188
+ }
189
+ const rows = institutions.map((i) => ({ ...i, signedIn: sf.signedIn(i.key, home), local: state.institutions[i.key] || {}, lastRun: latest.find((r) => r.institutionKey === i.key) || null }));
190
+ emit({ home, chrome, banks: rows }, [
191
+ ...rows.map((b) => `${b.key} ${b.name} · ${b.signedIn ? `signed in ${ago(b.local.loggedInAt)}` : 'not signed in'} · last fetch ${b.lastRun ? `${b.lastRun.status} ${ago(b.lastRun.finishedAt)}` : 'never'}\n${b.accounts.map((a) => ` ${a.name}${a.last4 ? '' : ' (no last four digits: its statements may need filing by hand)'}`).join('\n')}`),
192
+ ...(rows.some((b) => !b.signedIn) ? ['', `Next: coinpay finances statements login ${rows.find((b) => !b.signedIn).key}`] : []),
193
+ ...(chrome ? [] : ['', sf.NO_CHROME]),
194
+ ].join('\n'));
195
+ return EXIT.OK;
196
+ }
197
+ if (action === 'login' || action === 'assist') {
198
+ if (rest.length !== 1) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances statements ${action} <bank>`);
199
+ if (!chrome) throw new CliExit(EXIT.FAILURE, sf.NO_CHROME);
200
+ const institution = sf.pickInstitution(institutions, rest[0]);
201
+ const state = sf.loadLocal(home);
202
+ const local = (state.institutions[institution.key] ||= {});
203
+ const urls = sf.startUrls(institution, local.start);
204
+ const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false });
205
+ if (action === 'login') {
206
+ const url = typeof flags.url === 'string' ? flags.url : urls.login;
207
+ if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
208
+ out(`Opening ${institution.name}. Sign in, go to the page that lists your statements, then close the window.`);
209
+ const last = await sf.loginWindow(browser, url);
210
+ await browser.close();
211
+ local.loggedInAt = new Date().toISOString();
212
+ if (last) local.start = last;
213
+ sf.saveLocal(state, home);
214
+ emit({ bank: institution.key, start: local.start || urls.fetch }, last ? `Saved. fetch will start at ${last}` : 'Saved. The window closed on a sign-in page, so fetch starts from the bank\'s known page.');
215
+ return EXIT.OK;
216
+ }
217
+ out(`Opening ${institution.name}. Download each statement you want; each is imported as it lands. Close the window when done.`);
218
+ let kept = 0;
219
+ try {
220
+ await sf.assistWindow(browser, {
221
+ start: urls.fetch,
222
+ onFile: async (download) => {
223
+ const result = await sf.keepStatement({ institution, download, state, home, importStatement: bound.importStatement, how: 'assist' });
224
+ if (result.status === 'imported') kept += 1;
225
+ progress(` ${result.status}: ${result.entry ? result.entry.path : result.name}${result.entry && result.entry.importError ? ` (${result.entry.importError})` : ''}`);
226
+ sf.saveLocal(state, home);
227
+ },
228
+ });
229
+ } finally {
230
+ await browser.close();
231
+ }
232
+ emit({ bank: institution.key, imported: kept }, `${kept} imported`);
233
+ return EXIT.OK;
234
+ }
235
+ // fetch
236
+ const since = typeof flags.since === 'string' ? flags.since : null;
237
+ if (since && !/^\d{4}-(0[1-9]|1[0-2])$/.test(since)) throw new CliExit(EXIT.INVALID, '--since is YYYY-MM');
238
+ const max = flags.max !== undefined ? Number(flags.max) : 24;
239
+ if (!Number.isInteger(max) || max < 1 || max > 500) throw new CliExit(EXIT.INVALID, '--max is a whole number from 1 to 500');
240
+ const render = flags.render !== undefined ? Number(flags.render) : 25;
241
+ if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
242
+ const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress });
243
+ emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} imported, ${r.duplicates} already had, ${r.unmatched} unmatched${r.message ? ` (${r.message})` : ''}`).join('\n'));
244
+ return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
245
+ } catch (err) {
246
+ if (err instanceof sf.StatementFetchError) throw new CliExit(EXIT.INVALID, err.message);
247
+ throw err;
248
+ }
249
+ }
250
+
126
251
  /**
127
252
  * Run one subcommand. `ctx` supplies the client and the output channels.
128
253
  * @returns {Promise<number>} an exit code
@@ -414,7 +539,8 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
414
539
  emit(data, data.note);
415
540
  return EXIT.OK;
416
541
  }
417
- throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>]');
542
+ if (STATEMENT_FETCH_ACTIONS.has(action)) return await runStatementFetchAction(action, args.slice(1), flags, { client, out, emit, progress });
543
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>|banks|login <bank>|fetch [bank…]|assist <bank>|coverage|runs|local|retry]');
418
544
  }
419
545
 
420
546
  case 'books': {
@@ -256,6 +256,22 @@ export async function reconcileFinanceStatement(client, statementId, {
256
256
  return data.reconciliation;
257
257
  }
258
258
 
259
+ /** Report one bank's statement fetch run (counts only). */
260
+ export async function reportStatementFetchRun(client, run) {
261
+ const data = await call(client, '/finances/statements/fetch-runs', { method: 'POST', body: JSON.stringify(run) });
262
+ return data.run;
263
+ }
264
+
265
+ /** `{latest, runs}`: the newest fetch run per bank, and the recent history. */
266
+ export async function listStatementFetchRuns(client, { limit } = {}) {
267
+ return call(client, `/finances/statements/fetch-runs${query({ limit })}`);
268
+ }
269
+
270
+ /** Which of the last `months` months have a statement, per account, plus each bank's latest fetch. */
271
+ export async function getStatementCoverage(client, { months } = {}) {
272
+ return call(client, `/finances/statements/coverage${query({ months })}`);
273
+ }
274
+
259
275
  // ── Books ──
260
276
 
261
277
  /** Rows awaiting review (default) plus the category vocabularies. */
package/src/mcp.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `coinpay mcp`: CoinPay as an MCP server over stdio.
3
+ *
4
+ * Newline-delimited JSON-RPC 2.0, no SDK dependency: initialize, ping,
5
+ * tools/list and tools/call are the whole surface. stdout carries protocol
6
+ * messages only; anything human goes to stderr.
7
+ *
8
+ * It runs on the merchant's machine with their `coinpay login` session, which
9
+ * is what lets `statements_fetch` work at all: the bank sessions behind it
10
+ * live in local Chrome profiles that CoinPay's servers never see.
11
+ */
12
+
13
+ import { createInterface } from 'node:readline';
14
+
15
+ import * as reports from './finances-reports.js';
16
+ import { listFinanceAccounts } from './finances.js';
17
+ import * as sf from './statements-fetch.js';
18
+
19
+ export const PROTOCOL_VERSION = '2025-06-18';
20
+
21
+ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
22
+
23
+ /** Every tool: name, description, JSON schema, and a handler taking (args, ctx). */
24
+ export const TOOLS = [
25
+ {
26
+ name: 'finance_accounts',
27
+ description: 'List the linked bank and card accounts (institution, name, kind, balance, id).',
28
+ inputSchema: { type: 'object', properties: { includeHidden: { type: 'boolean' } }, additionalProperties: false },
29
+ handler: async (args, { client }) => text({ accounts: await listFinanceAccounts(client, { includeHidden: args.includeHidden === true }) }),
30
+ },
31
+ {
32
+ name: 'statements_list',
33
+ description: 'List PDF statements in the CoinPay statement library, optionally for one account or period (2026-08, 2026-Q3).',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: { accountId: { type: 'string' }, period: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 500 } },
37
+ additionalProperties: false,
38
+ },
39
+ handler: async (args, { client }) => text({ statements: await reports.listFinanceStatements(client, { accountId: args.accountId, period: args.period, limit: args.limit }) }),
40
+ },
41
+ {
42
+ name: 'statements_coverage',
43
+ description: 'For each account, which of the last N months have a statement (have), which do not (missing), and the current month (open); plus the latest statement fetch per bank.',
44
+ inputSchema: { type: 'object', properties: { months: { type: 'integer', minimum: 1, maximum: 36 } }, additionalProperties: false },
45
+ handler: async (args, { client }) => text(await reports.getStatementCoverage(client, { months: args.months })),
46
+ },
47
+ {
48
+ name: 'statements_fetch_runs',
49
+ description: 'Recent statement fetch runs: per bank, ok / login_needed / no_statements / error, with counts.',
50
+ inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 1000 } }, additionalProperties: false },
51
+ handler: async (args, { client }) => text(await reports.listStatementFetchRuns(client, { limit: args.limit })),
52
+ },
53
+ {
54
+ name: 'statements_banks',
55
+ description: 'The linked banks and whether each is signed in on this machine for statement downloads. A bank that is not must be signed in by a person: `coinpay finances statements login <bank>`.',
56
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
57
+ handler: async (_args, { client, home }) => {
58
+ const institutions = sf.groupInstitutions(await listFinanceAccounts(client));
59
+ const state = sf.loadLocal(home);
60
+ return text({
61
+ chrome: sf.findChrome(),
62
+ banks: institutions.map((i) => ({ key: i.key, name: i.name, accounts: i.accounts.map((a) => a.name), signedIn: sf.signedIn(i.key, home), ...state.institutions[i.key] })),
63
+ });
64
+ },
65
+ },
66
+ {
67
+ name: 'statements_fetch',
68
+ description:
69
+ 'Download every new PDF statement from the signed-in banks (or the ones named) in a headless browser on this machine and import each into the CoinPay statement library. Takes minutes. Banks needing a sign-in are reported, never signed in to.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ banks: { type: 'array', items: { type: 'string' }, description: 'Bank keys such as "chase"; all signed-in banks when omitted' },
74
+ since: { type: 'string', pattern: '^\\d{4}-(0[1-9]|1[0-2])$', description: 'Skip statements that closed before this month (YYYY-MM)' },
75
+ max: { type: 'integer', minimum: 1, maximum: 500, description: 'At most this many new statements per bank (default 24)' },
76
+ },
77
+ additionalProperties: false,
78
+ },
79
+ handler: async (args, { client, home, log }) => {
80
+ const results = await sf.runStatementFetch({
81
+ api: await sf.clientApi(client),
82
+ banks: Array.isArray(args.banks) ? args.banks : [],
83
+ since: args.since || null,
84
+ max: args.max || 24,
85
+ home,
86
+ log,
87
+ client: 'coinpay-mcp',
88
+ });
89
+ return { ...text({ banks: results }), isError: false };
90
+ },
91
+ },
92
+ ];
93
+
94
+ /** Answer one JSON-RPC message; null for a notification. */
95
+ export async function handleMessage(message, ctx) {
96
+ const { id, method, params = {} } = message || {};
97
+ const reply = (result) => (id === undefined ? null : { jsonrpc: '2.0', id, result });
98
+ const fail = (code, msg) => (id === undefined ? null : { jsonrpc: '2.0', id, error: { code, message: msg } });
99
+
100
+ switch (method) {
101
+ case 'initialize':
102
+ return reply({
103
+ protocolVersion: typeof params.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
104
+ capabilities: { tools: {} },
105
+ serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
106
+ instructions:
107
+ 'CoinPay finances. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`.',
108
+ });
109
+ case 'notifications/initialized':
110
+ case 'notifications/cancelled':
111
+ return null;
112
+ case 'ping':
113
+ return reply({});
114
+ case 'tools/list':
115
+ return reply({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
116
+ case 'tools/call': {
117
+ const tool = TOOLS.find((t) => t.name === params.name);
118
+ if (!tool) return fail(-32602, `Unknown tool: ${params.name}`);
119
+ try {
120
+ const client = await ctx.getClient();
121
+ return reply(await tool.handler(params.arguments || {}, { ...ctx, client }));
122
+ } catch (err) {
123
+ return reply({ ...text(`${err && err.message ? err.message : String(err)}${err && err.code ? ` (${err.code})` : ''}`), isError: true });
124
+ }
125
+ }
126
+ default:
127
+ return id === undefined ? null : fail(-32601, `Method not found: ${method}`);
128
+ }
129
+ }
130
+
131
+ /** Serve on stdin/stdout until stdin closes. Calls run concurrently; replies carry their id. */
132
+ export async function serveStdio({ getClient, version, home = sf.statementsHome(), input = process.stdin, output = process.stdout, errOut = (line) => process.stderr.write(`${line}\n`) }) {
133
+ const ctx = { getClient, version, home, log: errOut };
134
+ const lines = createInterface({ input, crlfDelay: Infinity });
135
+ const pending = new Set();
136
+ for await (const line of lines) {
137
+ if (!line.trim()) continue;
138
+ let message;
139
+ try {
140
+ message = JSON.parse(line);
141
+ } catch {
142
+ output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
143
+ continue;
144
+ }
145
+ const task = handleMessage(message, ctx).then((response) => {
146
+ if (response) output.write(`${JSON.stringify(response)}\n`);
147
+ });
148
+ pending.add(task);
149
+ task.finally(() => pending.delete(task));
150
+ }
151
+ await Promise.all(pending);
152
+ }