@rvanbaalen/ofxreader 1.2.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 Robin van Baalen
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,185 @@
1
+ # ofxreader
2
+
3
+ [![release-please](https://github.com/rvanbaalen/ofxreader/actions/workflows/release-please.yml/badge.svg)](https://github.com/rvanbaalen/ofxreader/actions/workflows/release-please.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
5
+
6
+ A command-line tool **and MCP server** for reading and querying **OFX 2.x (XML)**
7
+ financial files (bank and credit-card statement exports). Built for LLM/agent
8
+ use: deterministic JSON output, composable filters, structured errors, and a
9
+ self-documenting `--llm` mode.
10
+
11
+ > Package name: **`@rvanbaalen/ofxreader`** — published to GitHub Packages.
12
+
13
+ ## Requirements
14
+
15
+ - **Node.js ≥ 24** (uses native TypeScript type-stripping — no build step).
16
+ Works on Node ≥ 22.18 too. An `.nvmrc` pins the project to Node 24: `nvm use`.
17
+
18
+ ## Install
19
+
20
+ ### From GitHub Packages
21
+
22
+ The package is published to GitHub Packages as `@rvanbaalen/ofxreader`. Point the
23
+ `@rvanbaalen` scope at the GitHub registry and authenticate with a token that has
24
+ the `read:packages` scope (GitHub Packages requires auth even for public
25
+ packages):
26
+
27
+ ```sh
28
+ # ~/.npmrc
29
+ @rvanbaalen:registry=https://npm.pkg.github.com
30
+ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
31
+ ```
32
+
33
+ ```sh
34
+ npm install -g @rvanbaalen/ofxreader # installs the `ofxreader` and `ofx-mcp` bins
35
+ ofxreader --llm
36
+ ```
37
+
38
+ ### From source
39
+
40
+ ```sh
41
+ git clone https://github.com/rvanbaalen/ofxreader.git
42
+ cd ofxreader
43
+ nvm use # Node 24 (see .nvmrc)
44
+ npm install # deps: fast-xml-parser, MCP SDK, zod
45
+
46
+ # Symlink `ofxreader` into /usr/local/bin so you can run it anywhere.
47
+ # /usr/local/bin usually needs sudo on macOS:
48
+ sudo npm run install-cli
49
+
50
+ # ...or install into a writable directory of your choice:
51
+ OFXREADER_BIN_DIR="$HOME/.local/bin" npm run install-cli
52
+
53
+ npm run uninstall-cli # remove the symlink
54
+ ```
55
+
56
+ Without installing, run it directly: `node bin/ofxreader.ts <command> <file>`.
57
+
58
+ ## Usage
59
+
60
+ ```sh
61
+ ofxreader <command> <file.ofx> [options]
62
+ ofxreader --llm # full machine-readable usage guide (for LLMs/agents)
63
+ ofxreader --help # short usage
64
+ ofxreader --version
65
+ ```
66
+
67
+ ### Commands
68
+
69
+ | Command | Output |
70
+ |------------------------|--------|
71
+ | `summary <file>` | JSON array — one object per statement: account, currency, statement period, ledger & available balance, transaction counts, and totals (credits / debits / net). |
72
+ | `accounts <file>` | JSON array of accounts (`id`, `type`, `bankId`, `branchId`). |
73
+ | `transactions <file>` | `{ total, count, transactions: [...] }` — `total` is matches found, `count` is rows returned (differs when `--limit` is set). |
74
+ | `vendors` | List learned vendor aliases (no file argument). |
75
+ | `vendor-learn "<vendor>" "<descriptor>" […]` | Teach raw descriptors for a vendor (no file argument). |
76
+
77
+ ### Transaction filters
78
+
79
+ | Flag | Meaning |
80
+ |------|---------|
81
+ | `--from YYYY-MM-DD` / `--to YYYY-MM-DD` | Posted-date range (inclusive). |
82
+ | `--min N` / `--max N` | Signed-amount range. Use the `=` form for negatives: `--min=-100`. |
83
+ | `--type debit\|credit` | `debit` = amount < 0 (money out); `credit` = amount > 0 (money in). |
84
+ | `--search TEXT` | Case-insensitive match over name + memo + payee. |
85
+ | `--regex` | Treat `--search` as a JavaScript regular expression. |
86
+ | `--account ACCTID` | Restrict to one account. |
87
+ | `--vendor "Name"` | Resolve a learned vendor alias. Result holds only confirmed matches, plus a `vendorCandidates` list (fuzzy, unconfirmed) to learn from. See [Vendor learning](#vendor-learning). |
88
+ | `--limit N` | Return at most N rows. |
89
+ | `--pretty` | Indent JSON (default is compact). |
90
+
91
+ ### Examples
92
+
93
+ ```sh
94
+ ofxreader summary statement.ofx --pretty
95
+ ofxreader transactions statement.ofx --from 2024-01-01 --to 2024-03-31
96
+ ofxreader transactions statement.ofx --type debit --search amazon
97
+ ofxreader transactions statement.ofx --search "^ACME" --regex --limit 50
98
+ ```
99
+
100
+ ### Output contract
101
+
102
+ - **Success** → JSON on **stdout**, exit `0`.
103
+ - **Failure** → `{"error":{"code","message"}}` on **stderr**, non-zero exit.
104
+ Codes: `USAGE` (2), `FILE_NOT_FOUND` (1), `READ_ERROR` (1),
105
+ `NOT_OFX2` (1 — OFX 1.x SGML is rejected), `PARSE_ERROR` (1).
106
+
107
+ Amounts are signed numbers; dates are ISO 8601 strings.
108
+
109
+ ## Vendor learning
110
+
111
+ OFX descriptors are noisy and rarely equal the brand name (`SQ *JASONS CARO 0123`,
112
+ `TST* JASONSCAROUSEL`). ofxreader keeps a local **vendor alias store** mapping a
113
+ canonical vendor name to the descriptors that belong to it, so a question like
114
+ *"what did I spend at Jason's Carousel in April"* resolves to exactly the right records.
115
+
116
+ - A `--vendor` query returns **only confirmed** matches, plus a `vendorCandidates` list
117
+ (fuzzy, unconfirmed descriptors) ranked by similarity.
118
+ - Confirm a candidate with the user, persist it, and future queries are deterministic.
119
+
120
+ ```sh
121
+ # 1. Ask — confirmed matches + candidates to confirm
122
+ ofxreader transactions statement.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
123
+
124
+ # 2. Teach the confirmed descriptors
125
+ ofxreader vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123" "TST* JASONSCAROUSEL"
126
+
127
+ # 3. Re-run step 1 — now deterministic
128
+ ```
129
+
130
+ The store lives at `$OFXREADER_VENDORS`, else `$XDG_CONFIG_HOME/ofxreader/vendors.json`
131
+ (fallback `~/.config/ofxreader/vendors.json`). It is the source of truth; nothing is
132
+ sent anywhere.
133
+
134
+ ## Use as an MCP server
135
+
136
+ The same parser is exposed as a local [Model Context Protocol](https://modelcontextprotocol.io)
137
+ server (stdio transport) so Claude can work with OFX files directly. It registers
138
+ these tools covering every CLI capability:
139
+
140
+ | Tool | Input | Returns |
141
+ |------|-------|---------|
142
+ | `ofx_summary` | `path` | per-statement summaries |
143
+ | `ofx_accounts` | `path` | account list |
144
+ | `ofx_transactions` | `path` + filters (`from`, `to`, `min`, `max`, `type`, `search`, `regex`, `account`, `limit`, `vendor`) | `{ total, count, transactions[] }` (plus `vendorCandidates` when `vendor` is set) |
145
+ | `ofx_vendor_learn` | `vendor`, `descriptors[]` | the updated vendor entry |
146
+ | `ofx_vendors` | — | learned vendor aliases |
147
+
148
+ It also exposes one **resource** template:
149
+
150
+ | Resource | URI | Returns |
151
+ |----------|-----|---------|
152
+ | `ofx-balances` | `ofx:/absolute/path/to/file.ofx` | Ledger & available balance per account, each stated with its as-of date — e.g. `Balance at 2024-03-31 is 4327.87 USD` |
153
+
154
+ Run it directly with `npm run mcp` (or `node bin/ofx-mcp.ts`).
155
+
156
+ ### Claude Desktop
157
+
158
+ Add it to `~/Library/Application Support/Claude/claude_desktop_config.json`, then
159
+ restart Claude Desktop. Use an **absolute** path to a Node ≥ 24 binary — the
160
+ desktop app launches with a minimal PATH that won't include an nvm-managed `node`:
161
+
162
+ ```json
163
+ {
164
+ "mcpServers": {
165
+ "ofxreader": {
166
+ "command": "/Users/robin/.nvm/versions/node/v24.13.1/bin/node",
167
+ "args": ["/Users/robin/Sites/projects/ofxreader/bin/ofx-mcp.ts"]
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ ### Claude Code
174
+
175
+ ```sh
176
+ claude mcp add ofxreader -- /Users/robin/.nvm/versions/node/v24.13.1/bin/node \
177
+ /Users/robin/Sites/projects/ofxreader/bin/ofx-mcp.ts
178
+ ```
179
+
180
+ ## Development
181
+
182
+ ```sh
183
+ npm test # node:test suite (runs .ts directly)
184
+ npm run typecheck # tsc --noEmit (type-check only; never emits)
185
+ ```
package/bin/ofx-mcp.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "../src/mcp.ts";
4
+
5
+ const server = createServer();
6
+ const transport = new StdioServerTransport();
7
+ await server.connect(transport);
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.ts";
3
+
4
+ process.exitCode = run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@rvanbaalen/ofxreader",
3
+ "version": "1.2.0",
4
+ "description": "CLI for reading and querying OFX 2.x (XML) financial files. Built for LLM/agent use: deterministic JSON output and a self-documenting --llm mode.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ofxreader": "bin/ofxreader.ts",
8
+ "ofx-mcp": "bin/ofx-mcp.ts"
9
+ },
10
+ "engines": {
11
+ "node": ">=24"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "ofxreader": "node bin/ofxreader.ts",
21
+ "mcp": "node bin/ofx-mcp.ts",
22
+ "install-cli": "node scripts/install.ts",
23
+ "uninstall-cli": "node scripts/install.ts uninstall",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "node --test test/*.test.ts"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/rvanbaalen/ofxreader.git"
30
+ },
31
+ "homepage": "https://github.com/rvanbaalen/ofxreader#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/rvanbaalen/ofxreader/issues"
34
+ },
35
+ "author": "Robin van Baalen",
36
+ "keywords": [
37
+ "ofx",
38
+ "qfx",
39
+ "cli",
40
+ "mcp",
41
+ "finance",
42
+ "bank",
43
+ "transactions",
44
+ "llm",
45
+ "agent"
46
+ ],
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "fast-xml-parser": "^5.8.0",
54
+ "zod": "^4.4.3"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^25.9.1",
58
+ "typescript": "^6.0.3"
59
+ }
60
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ import { readOfxFile, parseOfx, OfxError } from "./parser.ts";
4
+ import { buildDocument } from "./model.ts";
5
+ import { filterTransactions } from "./query.ts";
6
+ import type { TransactionFilters } from "./query.ts";
7
+ import { summaries, uniqueAccounts } from "./report.ts";
8
+ import { resolveVendorQuery } from "./vendors/resolve.ts";
9
+ import { load as loadVendors, save as saveVendors, learn as learnVendor, today } from "./vendors/store.ts";
10
+ import { emit, emitError } from "./output.ts";
11
+ import { HELP_TEXT, LLM_INSTRUCTIONS } from "./help.ts";
12
+ import { getVersion } from "./version.ts";
13
+
14
+ const FILE_COMMANDS = ["summary", "accounts", "transactions"] as const;
15
+ const ALL_COMMANDS = "summary | accounts | transactions | vendors | vendor-learn";
16
+
17
+ const OPTIONS = {
18
+ from: { type: "string" },
19
+ to: { type: "string" },
20
+ min: { type: "string" },
21
+ max: { type: "string" },
22
+ type: { type: "string" },
23
+ search: { type: "string" },
24
+ regex: { type: "boolean" },
25
+ account: { type: "string" },
26
+ limit: { type: "string" },
27
+ vendor: { type: "string" },
28
+ pretty: { type: "boolean" },
29
+ llm: { type: "boolean" },
30
+ help: { type: "boolean", short: "h" },
31
+ version: { type: "boolean", short: "v" },
32
+ } as const;
33
+
34
+ /** Run the CLI. Returns the process exit code. */
35
+ export function run(argv: string[]): number {
36
+ let values: Record<string, string | boolean | undefined>;
37
+ let positionals: string[];
38
+ try {
39
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
40
+ values = parsed.values;
41
+ positionals = parsed.positionals;
42
+ } catch (err) {
43
+ emitError("USAGE", (err as Error).message);
44
+ return 2;
45
+ }
46
+
47
+ const pretty = values.pretty === true;
48
+
49
+ if (values.version === true) {
50
+ process.stdout.write(getVersion() + "\n");
51
+ return 0;
52
+ }
53
+ if (values.llm === true) {
54
+ process.stdout.write(LLM_INSTRUCTIONS + "\n");
55
+ return 0;
56
+ }
57
+ if (values.help === true) {
58
+ process.stdout.write(HELP_TEXT + "\n");
59
+ return 0;
60
+ }
61
+
62
+ const command = positionals[0];
63
+ if (command == null) {
64
+ process.stderr.write(HELP_TEXT + "\n");
65
+ return 2;
66
+ }
67
+
68
+ // Store-based commands (no OFX file argument).
69
+ if (command === "vendors") {
70
+ try {
71
+ emit(loadVendors().vendors, pretty);
72
+ return 0;
73
+ } catch (err) {
74
+ return reportError(err);
75
+ }
76
+ }
77
+ if (command === "vendor-learn") {
78
+ const name = positionals[1];
79
+ const descriptors = positionals.slice(2);
80
+ if (name == null || descriptors.length === 0) {
81
+ emitError("USAGE", 'Usage: ofxreader vendor-learn "<vendor>" "<descriptor>" [more...]');
82
+ return 2;
83
+ }
84
+ try {
85
+ const store = loadVendors();
86
+ const entry = learnVendor(store, name, descriptors, today());
87
+ saveVendors(store);
88
+ emit({ vendor: name, ...entry }, pretty);
89
+ return 0;
90
+ } catch (err) {
91
+ return reportError(err);
92
+ }
93
+ }
94
+
95
+ if (!(FILE_COMMANDS as readonly string[]).includes(command)) {
96
+ emitError("USAGE", `Unknown command "${command}". Expected: ${ALL_COMMANDS}. Run --llm for help.`);
97
+ return 2;
98
+ }
99
+
100
+ const file = positionals[1];
101
+ if (file == null) {
102
+ emitError("USAGE", `Missing file argument. Usage: ofxreader ${command} <file.ofx>`);
103
+ return 2;
104
+ }
105
+
106
+ try {
107
+ const doc = buildDocument(parseOfx(readOfxFile(file)));
108
+
109
+ if (command === "summary") {
110
+ emit(summaries(doc.statements), pretty);
111
+ } else if (command === "accounts") {
112
+ emit(uniqueAccounts(doc.statements), pretty);
113
+ } else {
114
+ const filters = buildFilters(values);
115
+ if (typeof values.vendor === "string") {
116
+ emit(resolveVendorQuery(loadVendors(), doc.statements, values.vendor, filters), pretty);
117
+ } else {
118
+ const all = doc.statements.flatMap((s) => s.transactions);
119
+ emit(filterTransactions(all, filters), pretty);
120
+ }
121
+ }
122
+ return 0;
123
+ } catch (err) {
124
+ return reportError(err);
125
+ }
126
+ }
127
+
128
+ function reportError(err: unknown): number {
129
+ if (err instanceof OfxError) {
130
+ emitError(err.code, err.message);
131
+ return err.code === "USAGE" ? 2 : 1;
132
+ }
133
+ emitError("INTERNAL", (err as Error).message);
134
+ return 1;
135
+ }
136
+
137
+ function buildFilters(values: Record<string, string | boolean | undefined>): TransactionFilters {
138
+ const f: TransactionFilters = {};
139
+
140
+ if (typeof values.from === "string") f.from = checkDate(values.from, "--from");
141
+ if (typeof values.to === "string") f.to = checkDate(values.to, "--to");
142
+ if (typeof values.min === "string") f.min = parseNum(values.min, "--min");
143
+ if (typeof values.max === "string") f.max = parseNum(values.max, "--max");
144
+
145
+ if (typeof values.type === "string") {
146
+ if (values.type !== "debit" && values.type !== "credit") {
147
+ throw new OfxError("USAGE", `--type must be "debit" or "credit", got "${values.type}".`);
148
+ }
149
+ f.type = values.type;
150
+ }
151
+
152
+ if (typeof values.search === "string") f.search = values.search;
153
+ if (values.regex === true) f.regex = true;
154
+ if (typeof values.account === "string") f.account = values.account;
155
+
156
+ if (typeof values.limit === "string") {
157
+ const n = parseNum(values.limit, "--limit");
158
+ if (!Number.isInteger(n) || n < 0) {
159
+ throw new OfxError("USAGE", `--limit must be a non-negative integer, got "${values.limit}".`);
160
+ }
161
+ f.limit = n;
162
+ }
163
+
164
+ if (f.regex && f.search == null) {
165
+ throw new OfxError("USAGE", "--regex requires --search.");
166
+ }
167
+ return f;
168
+ }
169
+
170
+ function parseNum(value: string, flag: string): number {
171
+ const n = Number(value);
172
+ if (Number.isNaN(n)) throw new OfxError("USAGE", `${flag} must be a number, got "${value}".`);
173
+ return n;
174
+ }
175
+
176
+ function checkDate(value: string, flag: string): string {
177
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
178
+ throw new OfxError("USAGE", `${flag} must be YYYY-MM-DD, got "${value}".`);
179
+ }
180
+ return value;
181
+ }
package/src/dates.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * OFX datetime helpers.
3
+ *
4
+ * OFX dates look like: YYYYMMDD[HHMMSS[.SSS]][[+/-OFFSET[:TZNAME]]]
5
+ * Examples:
6
+ * "20240115"
7
+ * "20240115120000"
8
+ * "20240115120000.000"
9
+ * "20240115120000.000[-5:EST]"
10
+ * "20240115120000[+5.5:IST]"
11
+ */
12
+
13
+ const OFX_DATE_RE =
14
+ /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2})(?:\.(\d{1,3}))?)?(?:\[\s*([+-]?\d+(?:\.\d+)?)\s*(?::([^\]]*))?\])?$/;
15
+
16
+ /**
17
+ * Convert an OFX datetime to ISO 8601.
18
+ * Returns a full timestamp when a time component is present, otherwise a
19
+ * date-only string (YYYY-MM-DD). Returns null when the value can't be parsed.
20
+ */
21
+ export function ofxToIso(value: string | null | undefined): string | null {
22
+ if (value == null) return null;
23
+ const raw = String(value).trim();
24
+ const m = OFX_DATE_RE.exec(raw);
25
+ if (m == null) return null;
26
+
27
+ const [, y, mo, d, hh, mm, ss, ms, tz] = m;
28
+ if (hh == null) return `${y}-${mo}-${d}`;
29
+
30
+ const millis = ms != null ? `.${ms.padEnd(3, "0")}` : "";
31
+ const offset = tz != null ? formatOffset(tz) : "";
32
+ return `${y}-${mo}-${d}T${hh}:${mm}:${ss}${millis}${offset}`;
33
+ }
34
+
35
+ /** Date-only portion (YYYY-MM-DD) used for inclusive range comparisons. */
36
+ export function ofxToDateOnly(value: string | null | undefined): string | null {
37
+ const iso = ofxToIso(value);
38
+ return iso == null ? null : iso.slice(0, 10);
39
+ }
40
+
41
+ function formatOffset(tz: string): string {
42
+ const hours = Number(tz);
43
+ if (Number.isNaN(hours)) return "";
44
+ const sign = hours < 0 ? "-" : "+";
45
+ const abs = Math.abs(hours);
46
+ const wholeHours = Math.floor(abs);
47
+ const minutes = Math.round((abs - wholeHours) * 60);
48
+ const hh = String(wholeHours).padStart(2, "0");
49
+ const mm = String(minutes).padStart(2, "0");
50
+ return `${sign}${hh}:${mm}`;
51
+ }
package/src/help.ts ADDED
@@ -0,0 +1,130 @@
1
+ export const HELP_TEXT = `ofxreader — read & query OFX 2.x (XML) files. JSON in, JSON out.
2
+
3
+ USAGE
4
+ ofxreader <command> <file.ofx> [options]
5
+ ofxreader --llm # full machine-readable usage guide (for LLMs/agents)
6
+ ofxreader --version
7
+ ofxreader --help
8
+
9
+ COMMANDS
10
+ summary <file> Per-statement overview (account, period, balances, totals)
11
+ accounts <file> Accounts found in the file
12
+ transactions <file> Transactions, with optional filters
13
+ vendors List learned vendor aliases
14
+ vendor-learn "<vendor>" "<descriptor>" [more...] Teach descriptors for a vendor
15
+
16
+ TRANSACTION FILTERS
17
+ --from YYYY-MM-DD --to YYYY-MM-DD posted-date range (inclusive)
18
+ --min N --max N signed-amount range (e.g. --min=-100)
19
+ --type debit|credit debit = money out, credit = money in
20
+ --search TEXT --regex match name + memo + payee
21
+ --account ACCTID restrict to one account
22
+ --vendor "Name" resolve a learned vendor alias (+ candidates)
23
+ --limit N cap rows returned
24
+
25
+ GLOBAL
26
+ --pretty indent JSON (default: compact)
27
+
28
+ Run "ofxreader --llm" for output shapes, exit codes, and examples.`;
29
+
30
+ export const LLM_INSTRUCTIONS = `ofxreader — instructions for LLM/agent use
31
+ ==========================================
32
+
33
+ PURPOSE
34
+ Parse a bank or credit-card OFX 2.x (XML) export and return structured JSON for
35
+ accounts, balances, statement periods, and transactions — optionally filtered.
36
+ Output is deterministic JSON on stdout, so you can parse it directly.
37
+
38
+ INVOCATION
39
+ ofxreader <command> <file.ofx> [options]
40
+
41
+ COMMANDS
42
+ summary <file>
43
+ Per-statement overview. Returns a JSON array, one object per statement:
44
+ {
45
+ "account": { "id", "type", "bankId", "branchId" },
46
+ "currency": "USD" | null,
47
+ "period": { "start": ISO8601|null, "end": ISO8601|null },
48
+ "balance": { "amount": number, "asOf": ISO8601|null } | null,
49
+ "available": { "amount": number, "asOf": ISO8601|null } | null,
50
+ "counts": { "transactions": int, "credits": int, "debits": int },
51
+ "totals": { "credits": number, "debits": number, "net": number }
52
+ }
53
+
54
+ accounts <file>
55
+ JSON array of the accounts in the file:
56
+ { "id", "type", "bankId", "branchId" }
57
+
58
+ transactions <file> [filters]
59
+ JSON object:
60
+ { "total": int, "count": int, "transactions": [ Transaction, ... ] }
61
+ "total" = matches found; "count" = rows returned (differs when --limit is set).
62
+ Transaction:
63
+ {
64
+ "account": acctid, "id": fitid, "date": ISO8601|null,
65
+ "amount": number, // signed; negative = money out
66
+ "trnType": "DEBIT"|"CREDIT"|"CHECK"|"POS"|"FEE"|..., // raw OFX type
67
+ "name": string, "memo": string|null,
68
+ "payee": string|null, "checkNumber": string|null
69
+ }
70
+
71
+ vendors
72
+ JSON object mapping canonical vendor name -> { signatures[], raw[], updatedAt }.
73
+
74
+ vendor-learn "<vendor>" "<descriptor>" [more...]
75
+ Persist that these raw descriptors belong to the vendor (normalized into
76
+ signatures for deterministic matching). Returns the updated vendor entry.
77
+
78
+ TRANSACTION FILTERS (transactions command only; all optional, all combinable)
79
+ --from YYYY-MM-DD posted on/after this date (inclusive)
80
+ --to YYYY-MM-DD posted on/before this date (inclusive)
81
+ --min N signed amount >= N (use = for negatives, e.g. --min=-100)
82
+ --max N signed amount <= N (e.g. --max=-0.01 for outflows only)
83
+ --type debit|credit debit = amount < 0 (money out); credit = amount > 0 (money in)
84
+ --search TEXT case-insensitive substring over name + memo + payee
85
+ --regex treat --search as a JavaScript regular expression
86
+ --account ACCTID restrict to a single account (id from "accounts"/"summary")
87
+ --limit N return at most N rows ("total" still reports all matches)
88
+ --vendor "Name" resolve a learned vendor alias; results hold only CONFIRMED
89
+ matches and add "vendorCandidates" (fuzzy) to learn from
90
+
91
+ GLOBAL OPTIONS
92
+ --pretty indent JSON for humans (default is compact, token-efficient)
93
+ --llm print this guide
94
+ --version print version
95
+ --help print short usage
96
+
97
+ VENDOR LEARNING (match messy descriptors to a real vendor)
98
+ OFX descriptors are noisy ("SQ *JASONS CARO 0123") and rarely equal the brand name.
99
+ To answer "what did I spend at Jason's Carousel in April":
100
+ 1) transactions stmt.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
101
+ -> "transactions" = confirmed matches; "vendorCandidates" = fuzzy guesses, each
102
+ { "normalized", "examples": [raw...], "count", "similarity" }.
103
+ 2) If a candidate is right, confirm with the user, then persist it:
104
+ vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123" "TST* JASONSCAROUSEL"
105
+ 3) Re-run step 1 — matches are now deterministic.
106
+ Store: $OFXREADER_VENDORS, else ~/.config/ofxreader/vendors.json.
107
+
108
+ OUTPUT CONTRACT
109
+ Success: JSON on stdout, exit code 0.
110
+ Failure: JSON on stderr as {"error":{"code","message"}}, non-zero exit code.
111
+ USAGE (exit 2) bad/unknown arguments or missing file
112
+ FILE_NOT_FOUND (exit 1) path does not exist
113
+ READ_ERROR (exit 1) path unreadable / is a directory
114
+ NOT_OFX2 (exit 1) not an OFX 2.x XML file (OFX 1.x SGML is rejected)
115
+ PARSE_ERROR (exit 1) malformed XML / no <OFX> root
116
+ VENDOR_STORE_ERROR (exit 1) vendor alias store is unreadable / invalid JSON
117
+ Notes: amounts are signed numbers; dates are ISO 8601 strings; flags accept
118
+ both "--flag value" and "--flag=value" (use the "=" form for negative numbers).
119
+
120
+ EXAMPLES
121
+ ofxreader summary statement.ofx
122
+ ofxreader accounts statement.ofx --pretty
123
+ ofxreader transactions statement.ofx --from 2024-01-01 --to 2024-03-31
124
+ ofxreader transactions statement.ofx --type debit --search amazon
125
+ ofxreader transactions statement.ofx --min=-50 --max=-0.01 # small outflows
126
+ ofxreader transactions statement.ofx --search "^ACME" --regex --limit 50
127
+ ofxreader transactions statement.ofx --account 1234567890
128
+ ofxreader transactions statement.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
129
+ ofxreader vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123"
130
+ ofxreader vendors`;