financy 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 +190 -0
- package/README.md +139 -0
- package/dist/bin.js +750 -0
- package/dist/chunk-JZO2ARNB.js +492 -0
- package/dist/server-SIRWJMXP.js +199 -0
- package/package.json +65 -0
- package/skills/financy-setup/SKILL.md +177 -0
- package/skills/freshness-check/SKILL.md +139 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CliError,
|
|
4
|
+
EXIT,
|
|
5
|
+
STALE_THRESHOLD_DAYS,
|
|
6
|
+
VERSION,
|
|
7
|
+
fetchAccount,
|
|
8
|
+
fetchAccounts,
|
|
9
|
+
fetchBranches,
|
|
10
|
+
fetchCategories,
|
|
11
|
+
fetchConnection,
|
|
12
|
+
fetchConnections,
|
|
13
|
+
fetchProviders,
|
|
14
|
+
fetchRefresh,
|
|
15
|
+
fetchStatus,
|
|
16
|
+
fetchTransaction,
|
|
17
|
+
fetchTransactions,
|
|
18
|
+
getConnections,
|
|
19
|
+
loadConfig,
|
|
20
|
+
mintToken,
|
|
21
|
+
readCredentialsFile,
|
|
22
|
+
resolveEndpoints,
|
|
23
|
+
writeCredentialsFile
|
|
24
|
+
} from "./chunk-JZO2ARNB.js";
|
|
25
|
+
|
|
26
|
+
// src/run.ts
|
|
27
|
+
import { Command, CommanderError } from "commander";
|
|
28
|
+
|
|
29
|
+
// src/commands/status.ts
|
|
30
|
+
async function statusCommand(ctx) {
|
|
31
|
+
const { data, staleThresholdDays, connections } = await fetchStatus(ctx.config, ctx.now);
|
|
32
|
+
if (ctx.json) {
|
|
33
|
+
ctx.out(JSON.stringify({ data, staleThresholdDays }, null, 2));
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
ctx.out(renderTable(connections, ctx.now));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
function daysAgo(dataDate, now) {
|
|
40
|
+
if (!dataDate) return null;
|
|
41
|
+
const [y, m, d] = dataDate.split("-").map(Number);
|
|
42
|
+
if (y === void 0 || m === void 0 || d === void 0) return null;
|
|
43
|
+
const dataMidnight = Date.UTC(y, m - 1, d);
|
|
44
|
+
const nowMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
45
|
+
return Math.round((nowMidnight - dataMidnight) / 864e5);
|
|
46
|
+
}
|
|
47
|
+
function formatError(err) {
|
|
48
|
+
if (!err || typeof err !== "object") return err ? String(err) : null;
|
|
49
|
+
const e = err;
|
|
50
|
+
const code = e.code ?? e.type ?? e.errorCode;
|
|
51
|
+
const message = e.message ?? e.description ?? e.reason;
|
|
52
|
+
if (code || message) return `${code ?? "error"}: ${message ?? ""}`.trim();
|
|
53
|
+
return JSON.stringify(err);
|
|
54
|
+
}
|
|
55
|
+
function classify(c, days) {
|
|
56
|
+
if (c.status !== "ACTIVE") return { symbol: `\u2717 ${c.status}`, needsAttention: true };
|
|
57
|
+
if (days === null) return { symbol: "\u25CF no data yet", needsAttention: true };
|
|
58
|
+
if (days > STALE_THRESHOLD_DAYS) return { symbol: "\u25CF stale", needsAttention: true };
|
|
59
|
+
return { symbol: "\u2713 fresh", needsAttention: false };
|
|
60
|
+
}
|
|
61
|
+
function renderTable(items, now) {
|
|
62
|
+
const today = now.toISOString().slice(0, 10);
|
|
63
|
+
const lines = [
|
|
64
|
+
`Connections (data date vs today ${today}, stale > ${STALE_THRESHOLD_DAYS}d)`,
|
|
65
|
+
""
|
|
66
|
+
];
|
|
67
|
+
if (items.length === 0) {
|
|
68
|
+
lines.push(" No connections yet. Connect a bank in the Financy app.", "");
|
|
69
|
+
return lines.join("\n") + "\n";
|
|
70
|
+
}
|
|
71
|
+
let attention = 0;
|
|
72
|
+
const markWidth = Math.max(
|
|
73
|
+
...items.map((c) => classify(c, daysAgo(c.lastFetchedDataDate, now)).symbol.length)
|
|
74
|
+
);
|
|
75
|
+
for (const c of items) {
|
|
76
|
+
const days = daysAgo(c.lastFetchedDataDate, now);
|
|
77
|
+
const mark = classify(c, days);
|
|
78
|
+
if (mark.needsAttention) attention++;
|
|
79
|
+
const provider = (c.providerId ?? "(pending)").padEnd(14);
|
|
80
|
+
const through = c.lastFetchedDataDate && days !== null ? `data through ${c.lastFetchedDataDate} (${days}d ago)` : "no data fetched yet";
|
|
81
|
+
const expires = c.expiryDate ? ` \xB7 consent expires ${c.expiryDate}` : "";
|
|
82
|
+
lines.push(` ${mark.symbol.padEnd(markWidth)} ${provider} ${through}${expires}`);
|
|
83
|
+
const errText = formatError(c.error);
|
|
84
|
+
if (errText) lines.push(` ${errText}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push("");
|
|
87
|
+
if (attention > 0) {
|
|
88
|
+
const noun = attention === 1 ? "connection needs" : "connections need";
|
|
89
|
+
lines.push(
|
|
90
|
+
`${attention} ${noun} attention. Run financy refresh to fetch now (20 credits).`
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
lines.push("All connections are fresh.");
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n") + "\n";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/commands/setup.ts
|
|
99
|
+
import { join } from "path";
|
|
100
|
+
var CREDENTIALS_GUIDE = "financy needs your Financy API credentials: clientId, clientSecret, and userId.\nFind them in the Financy app \u2192 Settings \u2192 API.\nThis requires a registered Financy account on a paid plan (Starter or Pro) \u2014\nthe data API is not available on the free plan. Sign up at https://open-finance.ai\n\n";
|
|
101
|
+
async function setupCommand(ctx) {
|
|
102
|
+
if (!ctx.noInput) ctx.out(CREDENTIALS_GUIDE);
|
|
103
|
+
const credentials = ctx.noInput ? credentialsFromEnv(ctx.env) : await credentialsFromPrompts(ctx.prompt);
|
|
104
|
+
const endpoints = resolveEndpoints(ctx.env);
|
|
105
|
+
const config = { ...credentials, ...endpoints };
|
|
106
|
+
ctx.out("Validating\u2026\n");
|
|
107
|
+
try {
|
|
108
|
+
const token = await mintToken(config);
|
|
109
|
+
await getConnections(config, token);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof CliError && error.code === "NOT_AVAILABLE_ON_PLAN") {
|
|
112
|
+
await writeCredentialsFile(endpoints.configDir, credentials);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
await writeCredentialsFile(endpoints.configDir, credentials);
|
|
117
|
+
ctx.out(
|
|
118
|
+
`\u2713 Saved to ${join(endpoints.configDir, "config.json")} (permissions 600)
|
|
119
|
+
|
|
120
|
+
Try: financy status
|
|
121
|
+
`
|
|
122
|
+
);
|
|
123
|
+
return EXIT.OK;
|
|
124
|
+
}
|
|
125
|
+
function credentialsFromEnv(env) {
|
|
126
|
+
const clientId = env.FINANCY_CLIENT_ID;
|
|
127
|
+
const clientSecret = env.FINANCY_CLIENT_SECRET;
|
|
128
|
+
const userId = env.FINANCY_USER_ID;
|
|
129
|
+
if (!clientId || !clientSecret || !userId) {
|
|
130
|
+
throw new CliError(
|
|
131
|
+
EXIT.USAGE,
|
|
132
|
+
"NOT_CONFIGURED",
|
|
133
|
+
"--no-input requires FINANCY_CLIENT_ID, FINANCY_CLIENT_SECRET, and FINANCY_USER_ID"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return { clientId, clientSecret, userId };
|
|
137
|
+
}
|
|
138
|
+
async function credentialsFromPrompts(prompt) {
|
|
139
|
+
const clientId = await prompt({ label: "Client ID" });
|
|
140
|
+
const clientSecret = await prompt({ label: "Client secret", secret: true });
|
|
141
|
+
const userId = await prompt({ label: "User ID" });
|
|
142
|
+
return { clientId, clientSecret, userId };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/commands/config.ts
|
|
146
|
+
function maskId(v) {
|
|
147
|
+
if (!v) return "(unset)";
|
|
148
|
+
return v.length <= 8 ? "****" : `${v.slice(0, 4)}\u2026${v.slice(-4)}`;
|
|
149
|
+
}
|
|
150
|
+
async function configCommand(ctx) {
|
|
151
|
+
const endpoints = resolveEndpoints(ctx.env);
|
|
152
|
+
const file = await readCredentialsFile(endpoints.configDir) ?? {};
|
|
153
|
+
const resolve = (envKey, fileVal) => {
|
|
154
|
+
const envVal = ctx.env[envKey];
|
|
155
|
+
if (envVal) return { value: envVal, source: "env" };
|
|
156
|
+
if (fileVal) return { value: fileVal, source: "file" };
|
|
157
|
+
return { source: "unset" };
|
|
158
|
+
};
|
|
159
|
+
const clientId = resolve("FINANCY_CLIENT_ID", file.clientId);
|
|
160
|
+
const clientSecret = resolve("FINANCY_CLIENT_SECRET", file.clientSecret);
|
|
161
|
+
const userId = resolve("FINANCY_USER_ID", file.userId);
|
|
162
|
+
if (ctx.json) {
|
|
163
|
+
ctx.out(
|
|
164
|
+
JSON.stringify(
|
|
165
|
+
{
|
|
166
|
+
configDir: endpoints.configDir,
|
|
167
|
+
authTokenUrl: endpoints.authTokenUrl,
|
|
168
|
+
apiBaseUrl: endpoints.apiBaseUrl,
|
|
169
|
+
chatBaseUrl: endpoints.chatBaseUrl,
|
|
170
|
+
clientId: clientId.value ? maskId(clientId.value) : null,
|
|
171
|
+
clientIdSource: clientId.source,
|
|
172
|
+
clientSecretSet: clientSecret.source !== "unset",
|
|
173
|
+
clientSecretSource: clientSecret.source,
|
|
174
|
+
userId: userId.value ?? null,
|
|
175
|
+
userIdSource: userId.source
|
|
176
|
+
},
|
|
177
|
+
null,
|
|
178
|
+
2
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
return EXIT.OK;
|
|
182
|
+
}
|
|
183
|
+
const lines = [
|
|
184
|
+
"financy configuration",
|
|
185
|
+
"",
|
|
186
|
+
` config dir ${endpoints.configDir}`,
|
|
187
|
+
` auth url ${endpoints.authTokenUrl}`,
|
|
188
|
+
` api url ${endpoints.apiBaseUrl}`,
|
|
189
|
+
` chat url ${endpoints.chatBaseUrl}`,
|
|
190
|
+
"",
|
|
191
|
+
` clientId ${maskId(clientId.value)} (${clientId.source})`,
|
|
192
|
+
` clientSecret ${clientSecret.source !== "unset" ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (set)" : "(unset)"} (${clientSecret.source})`,
|
|
193
|
+
` userId ${userId.value ?? "(unset)"} (${userId.source})`
|
|
194
|
+
];
|
|
195
|
+
ctx.out(lines.join("\n") + "\n");
|
|
196
|
+
return EXIT.OK;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/commands/refresh.ts
|
|
200
|
+
function renderHuman(result) {
|
|
201
|
+
if (result.status === "already_running") {
|
|
202
|
+
return "A refresh is already in flight \u2014 watch it with financy status\n";
|
|
203
|
+
}
|
|
204
|
+
const scope = typeof result.connections === "number" ? `${result.connections} connection${result.connections === 1 ? "" : "s"}` : "your connections";
|
|
205
|
+
return `Refresh started for ${scope} (20 credits)
|
|
206
|
+
Data lands in a few minutes \u2014 check financy status
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
async function refreshCommand(ctx) {
|
|
210
|
+
const { data } = await fetchRefresh(ctx.config, ctx.now);
|
|
211
|
+
ctx.out(ctx.json ? JSON.stringify({ data }) : renderHuman(data));
|
|
212
|
+
return EXIT.OK;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/render.ts
|
|
216
|
+
function displayWidth(str) {
|
|
217
|
+
let width = 0;
|
|
218
|
+
for (const ch of str) {
|
|
219
|
+
const cp2 = ch.codePointAt(0);
|
|
220
|
+
if (isZeroWidth(cp2)) continue;
|
|
221
|
+
width += isWide(cp2) ? 2 : 1;
|
|
222
|
+
}
|
|
223
|
+
return width;
|
|
224
|
+
}
|
|
225
|
+
function isZeroWidth(cp2) {
|
|
226
|
+
return cp2 === 8203 || // zero-width space
|
|
227
|
+
cp2 >= 8204 && cp2 <= 8207 || // ZWNJ/ZWJ/LRM/RLM
|
|
228
|
+
cp2 === 65279 || // BOM / zero-width no-break space
|
|
229
|
+
// Combining marks
|
|
230
|
+
cp2 >= 768 && cp2 <= 879 || // combining diacritics
|
|
231
|
+
cp2 >= 1425 && cp2 <= 1469 || // Hebrew accents & niqqud
|
|
232
|
+
cp2 === 1471 || cp2 >= 1473 && cp2 <= 1474 || cp2 >= 1476 && cp2 <= 1477 || cp2 === 1479 || cp2 >= 1552 && cp2 <= 1562 || // Arabic marks
|
|
233
|
+
cp2 >= 1611 && cp2 <= 1631 || cp2 === 1648 || cp2 >= 6832 && cp2 <= 6911 || cp2 >= 7616 && cp2 <= 7679 || cp2 >= 8400 && cp2 <= 8447 || cp2 >= 65056 && cp2 <= 65071;
|
|
234
|
+
}
|
|
235
|
+
function isWide(cp2) {
|
|
236
|
+
return cp2 >= 4352 && cp2 <= 4447 || // Hangul Jamo
|
|
237
|
+
cp2 >= 11904 && cp2 <= 12350 || // CJK radicals, Kangxi
|
|
238
|
+
cp2 >= 12353 && cp2 <= 13311 || // Hiragana … CJK symbols
|
|
239
|
+
cp2 >= 13312 && cp2 <= 19903 || // CJK Ext A
|
|
240
|
+
cp2 >= 19968 && cp2 <= 40959 || // CJK Unified
|
|
241
|
+
cp2 >= 40960 && cp2 <= 42191 || // Yi
|
|
242
|
+
cp2 >= 44032 && cp2 <= 55203 || // Hangul syllables
|
|
243
|
+
cp2 >= 63744 && cp2 <= 64255 || // CJK compatibility
|
|
244
|
+
cp2 >= 65072 && cp2 <= 65103 || // CJK compatibility forms
|
|
245
|
+
cp2 >= 65280 && cp2 <= 65376 || // Fullwidth forms
|
|
246
|
+
cp2 >= 65504 && cp2 <= 65510 || cp2 >= 127744 && cp2 <= 129791 || // emoji & pictographs
|
|
247
|
+
cp2 >= 131072 && cp2 <= 262141;
|
|
248
|
+
}
|
|
249
|
+
var GAP = " ";
|
|
250
|
+
function cell(col, row) {
|
|
251
|
+
const v = col.value(row);
|
|
252
|
+
return v == null ? "" : String(v);
|
|
253
|
+
}
|
|
254
|
+
function padDisplay(str, width) {
|
|
255
|
+
return str + " ".repeat(Math.max(0, width - displayWidth(str)));
|
|
256
|
+
}
|
|
257
|
+
function renderTable2(rows, columns) {
|
|
258
|
+
const widths = columns.map(
|
|
259
|
+
(col) => Math.max(displayWidth(col.header), ...rows.map((r) => displayWidth(cell(col, r))))
|
|
260
|
+
);
|
|
261
|
+
const line = (cells) => cells.map((c, i) => padDisplay(c, widths[i])).join(GAP).trimEnd();
|
|
262
|
+
const lines = [
|
|
263
|
+
line(columns.map((c) => c.header)),
|
|
264
|
+
...rows.map((r) => line(columns.map((c) => cell(c, r))))
|
|
265
|
+
];
|
|
266
|
+
return lines.join("\n") + "\n";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/commands/resources.ts
|
|
270
|
+
function renderList(ctx, envelope, columns) {
|
|
271
|
+
if (ctx.json) {
|
|
272
|
+
ctx.out(
|
|
273
|
+
JSON.stringify(
|
|
274
|
+
{ data: envelope.data, count: envelope.count, nextPage: envelope.nextPage },
|
|
275
|
+
null,
|
|
276
|
+
2
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
ctx.out(renderTable2(envelope.data, columns));
|
|
281
|
+
}
|
|
282
|
+
return EXIT.OK;
|
|
283
|
+
}
|
|
284
|
+
function renderGet(ctx, envelope) {
|
|
285
|
+
if (ctx.json) {
|
|
286
|
+
ctx.out(JSON.stringify({ data: envelope.data }, null, 2));
|
|
287
|
+
} else {
|
|
288
|
+
ctx.out(JSON.stringify(envelope.data, null, 2) + "\n");
|
|
289
|
+
}
|
|
290
|
+
return EXIT.OK;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/commands/read-commands.ts
|
|
294
|
+
async function loadOrThrow(env) {
|
|
295
|
+
const result = await loadConfig(env);
|
|
296
|
+
if (!result.ok) {
|
|
297
|
+
throw new CliError(
|
|
298
|
+
EXIT.AUTH,
|
|
299
|
+
"NOT_CONFIGURED",
|
|
300
|
+
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return result.config;
|
|
304
|
+
}
|
|
305
|
+
var ils = (n) => {
|
|
306
|
+
const num = typeof n === "number" ? n : Number(n);
|
|
307
|
+
return typeof n !== "number" && typeof n !== "string" ? "\u2014" : !Number.isFinite(num) ? "\u2014" : (num < 0 ? "-" : "") + "\u20AA" + Math.abs(num).toLocaleString("en-US", { minimumFractionDigits: 2 });
|
|
308
|
+
};
|
|
309
|
+
var dash = (v) => v == null || typeof v === "object" ? "\u2014" : String(v);
|
|
310
|
+
function accountBalance(a) {
|
|
311
|
+
const b = a.balances?.find((x) => x.balanceType === "closingBooked") ?? a.balances?.[0];
|
|
312
|
+
return b?.balanceAmount?.amount;
|
|
313
|
+
}
|
|
314
|
+
function txnAmount(t) {
|
|
315
|
+
const charged = t.amount?.chargedAmount?.amount;
|
|
316
|
+
const chargedNum = Number(charged);
|
|
317
|
+
if (charged !== "" && charged != null && Number.isFinite(chargedNum)) return charged;
|
|
318
|
+
return t.amount?.originalAmount?.amount;
|
|
319
|
+
}
|
|
320
|
+
var CONNECTION_COLUMNS = [
|
|
321
|
+
{ header: "ID", value: (c) => c.id },
|
|
322
|
+
{ header: "PROVIDER", value: (c) => dash(c.providerId) },
|
|
323
|
+
{ header: "STATUS", value: (c) => c.status },
|
|
324
|
+
{ header: "DATA THROUGH", value: (c) => dash(c.lastFetchedDataDate) },
|
|
325
|
+
{ header: "EXPIRES", value: (c) => dash(c.expiryDate) },
|
|
326
|
+
{ header: "TXNS", value: (c) => dash(c.transactions) }
|
|
327
|
+
];
|
|
328
|
+
var ACCOUNT_COLUMNS = [
|
|
329
|
+
{ header: "ID", value: (a) => a.id },
|
|
330
|
+
{ header: "TYPE", value: (a) => dash(a.accountType) },
|
|
331
|
+
{ header: "NAME", value: (a) => dash(a.product ?? a.accountNumber) },
|
|
332
|
+
{ header: "PROVIDER", value: (a) => dash(a.providerId) },
|
|
333
|
+
{ header: "BALANCE", value: (a) => ils(accountBalance(a)) }
|
|
334
|
+
];
|
|
335
|
+
var TRANSACTION_COLUMNS = [
|
|
336
|
+
{ header: "DATE", value: (t) => dash(t.date?.transactionDate) },
|
|
337
|
+
{ header: "AMOUNT", value: (t) => ils(txnAmount(t)) },
|
|
338
|
+
{ header: "DESCRIPTION", value: (t) => dash(t.merchantName ?? t.description?.description) },
|
|
339
|
+
{ header: "CATEGORY", value: (t) => `${dash(t.category?.main)}/${dash(t.category?.sub)}` },
|
|
340
|
+
{ header: "TYPE", value: (t) => dash(t.type) },
|
|
341
|
+
{ header: "ID", value: (t) => t.id }
|
|
342
|
+
];
|
|
343
|
+
var PROVIDER_COLUMNS = [
|
|
344
|
+
{ header: "ID", value: (p) => dash(p.providerFriendlyId ?? p.id) },
|
|
345
|
+
{ header: "NAME", value: (p) => dash(p.name) },
|
|
346
|
+
{ header: "NATIVE", value: (p) => dash(p.nameNativeLanguage) },
|
|
347
|
+
{ header: "MODE", value: (p) => dash(p.mode) }
|
|
348
|
+
];
|
|
349
|
+
var BRANCH_COLUMNS = [
|
|
350
|
+
{ header: "BANK", value: (b) => dash(b.bankCode) },
|
|
351
|
+
{ header: "BRANCH", value: (b) => dash(b.branchCode ?? b.id) },
|
|
352
|
+
{ header: "NAME", value: (b) => dash(b.branchName ?? b.name) },
|
|
353
|
+
{ header: "CITY", value: (b) => dash(b.city) }
|
|
354
|
+
];
|
|
355
|
+
function registerReadCommands(program, base, setExit) {
|
|
356
|
+
const ctx = { json: base.json, out: base.out };
|
|
357
|
+
const connections = program.command("connections").description("Bank/card connections and their fetch state");
|
|
358
|
+
connections.command("list").option("--limit <n>", "page size").option("--cursor <token>", "resume from a nextPage cursor").option("--all", "auto-paginate to the end").option("--json", "machine-readable output").action(async (opts) => {
|
|
359
|
+
const config = await loadOrThrow(base.env);
|
|
360
|
+
const envelope = await fetchConnections(config, base.now, opts);
|
|
361
|
+
setExit(renderList(ctx, envelope, CONNECTION_COLUMNS));
|
|
362
|
+
});
|
|
363
|
+
connections.command("get <id>").option("--json", "machine-readable output").action(async (id) => {
|
|
364
|
+
const config = await loadOrThrow(base.env);
|
|
365
|
+
setExit(renderGet(ctx, await fetchConnection(config, base.now, id)));
|
|
366
|
+
});
|
|
367
|
+
const accounts = program.command("accounts").description("Accounts with balances");
|
|
368
|
+
accounts.command("list").option("--type <type>", "CHECKING|CARD|LOAN|SAVINGS|SECURITY").option("--connection <id>", "filter by connection").option("--limit <n>", "page size").option("--cursor <token>", "resume from a nextPage cursor").option("--all", "auto-paginate to the end").option("--json", "machine-readable output").action(async (opts) => {
|
|
369
|
+
const config = await loadOrThrow(base.env);
|
|
370
|
+
const envelope = await fetchAccounts(config, base.now, opts);
|
|
371
|
+
setExit(renderList(ctx, envelope, ACCOUNT_COLUMNS));
|
|
372
|
+
});
|
|
373
|
+
accounts.command("get <id>").option("--json", "machine-readable output").action(async (id) => {
|
|
374
|
+
const config = await loadOrThrow(base.env);
|
|
375
|
+
setExit(renderGet(ctx, await fetchAccount(config, base.now, id)));
|
|
376
|
+
});
|
|
377
|
+
const transactions = program.command("transactions").description("Transactions with filters");
|
|
378
|
+
transactions.command("list").option("--from <date>", "earliest transaction date (YYYY-MM-DD)").option("--to <date>", "latest transaction date (YYYY-MM-DD)").option("--account <id>", "filter by account").option("--connection <id>", "filter by connection").option("--type <type>", "BANK|CARD").option("--limit <n>", "page size").option("--cursor <token>", "resume from a nextPage cursor").option("--all", "auto-paginate to the end").option("--json", "machine-readable output").action(async (opts) => {
|
|
379
|
+
const config = await loadOrThrow(base.env);
|
|
380
|
+
const envelope = await fetchTransactions(config, base.now, opts);
|
|
381
|
+
setExit(renderList(ctx, envelope, TRANSACTION_COLUMNS));
|
|
382
|
+
});
|
|
383
|
+
transactions.command("get <id>").option("--json", "machine-readable output").action(async (id) => {
|
|
384
|
+
const config = await loadOrThrow(base.env);
|
|
385
|
+
setExit(renderGet(ctx, await fetchTransaction(config, base.now, id)));
|
|
386
|
+
});
|
|
387
|
+
program.command("categories").description("The category taxonomy (English + Hebrew)").option("--json", "machine-readable output").action(async () => {
|
|
388
|
+
const config = await loadOrThrow(base.env);
|
|
389
|
+
const data = await fetchCategories(config, base.now);
|
|
390
|
+
base.out(
|
|
391
|
+
base.json ? JSON.stringify({ data }, null, 2) : JSON.stringify(data, null, 2) + "\n"
|
|
392
|
+
);
|
|
393
|
+
setExit(EXIT.OK);
|
|
394
|
+
});
|
|
395
|
+
const providers = program.command("providers").description("Reference data: banks and branches");
|
|
396
|
+
providers.command("list").option("--json", "machine-readable output").action(async () => {
|
|
397
|
+
const config = await loadOrThrow(base.env);
|
|
398
|
+
setExit(renderList(ctx, await fetchProviders(config, base.now), PROVIDER_COLUMNS));
|
|
399
|
+
});
|
|
400
|
+
providers.command("branches").description("Bank branch reference data (no paging)").option("--json", "machine-readable output").action(async () => {
|
|
401
|
+
const config = await loadOrThrow(base.env);
|
|
402
|
+
setExit(renderList(ctx, await fetchBranches(config, base.now), BRANCH_COLUMNS));
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/commands/skills.ts
|
|
407
|
+
import { existsSync } from "fs";
|
|
408
|
+
import { cp, readFile, readdir, stat } from "fs/promises";
|
|
409
|
+
import { dirname, join as join2 } from "path";
|
|
410
|
+
import { fileURLToPath } from "url";
|
|
411
|
+
var INSTALL_SUBPATH = join2(".claude", "skills");
|
|
412
|
+
function resolveSkillsRoot(moduleDir = dirname(fileURLToPath(import.meta.url))) {
|
|
413
|
+
let dir = moduleDir;
|
|
414
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
415
|
+
dir = join2(dir, "..");
|
|
416
|
+
const candidate = join2(dir, "skills");
|
|
417
|
+
if (existsSync(candidate)) return candidate;
|
|
418
|
+
}
|
|
419
|
+
return join2(moduleDir, "..", "skills");
|
|
420
|
+
}
|
|
421
|
+
function parseFrontmatter(source) {
|
|
422
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
|
|
423
|
+
if (!match?.[1]) return {};
|
|
424
|
+
const result = {};
|
|
425
|
+
const lines = match[1].split(/\r?\n/);
|
|
426
|
+
for (let i = 0; i < lines.length; i++) {
|
|
427
|
+
const line = lines[i] ?? "";
|
|
428
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
429
|
+
if (!kv?.[1]) continue;
|
|
430
|
+
const [, key, rest = ""] = kv;
|
|
431
|
+
if (rest === ">" || rest === "|" || rest === ">-" || rest === "|-") {
|
|
432
|
+
const block = [];
|
|
433
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1] ?? "")) {
|
|
434
|
+
block.push((lines[++i] ?? "").trim());
|
|
435
|
+
}
|
|
436
|
+
result[key] = block.join(" ");
|
|
437
|
+
} else {
|
|
438
|
+
result[key] = rest.replace(/^["']|["']$/g, "");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
async function listSkills(skillsRoot) {
|
|
444
|
+
let entries;
|
|
445
|
+
try {
|
|
446
|
+
entries = (await readdir(skillsRoot, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
447
|
+
} catch {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
const skills = [];
|
|
451
|
+
for (const name of entries) {
|
|
452
|
+
const file = join2(skillsRoot, name, "SKILL.md");
|
|
453
|
+
try {
|
|
454
|
+
const front = parseFrontmatter(await readFile(file, "utf8"));
|
|
455
|
+
skills.push({ name: front.name ?? name, description: front.description ?? "" });
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return skills;
|
|
460
|
+
}
|
|
461
|
+
async function skillsListCommand(ctx) {
|
|
462
|
+
const skills = await listSkills(ctx.skillsRoot);
|
|
463
|
+
if (ctx.json) {
|
|
464
|
+
ctx.out(JSON.stringify({ data: skills, count: skills.length }, null, 2));
|
|
465
|
+
return EXIT.OK;
|
|
466
|
+
}
|
|
467
|
+
if (skills.length === 0) {
|
|
468
|
+
ctx.out("No skills are bundled with this build.\n");
|
|
469
|
+
return EXIT.OK;
|
|
470
|
+
}
|
|
471
|
+
const lines = ["Skills bundled with financy:", ""];
|
|
472
|
+
const width = Math.max(...skills.map((s) => s.name.length));
|
|
473
|
+
for (const skill of skills) {
|
|
474
|
+
lines.push(` ${skill.name.padEnd(width)} ${firstSentence(skill.description)}`);
|
|
475
|
+
}
|
|
476
|
+
lines.push("", `Install them with: financy skills install --all`);
|
|
477
|
+
ctx.out(lines.join("\n") + "\n");
|
|
478
|
+
return EXIT.OK;
|
|
479
|
+
}
|
|
480
|
+
function firstSentence(description) {
|
|
481
|
+
const sentence = description.split(/\.\s/)[0] ?? description;
|
|
482
|
+
return sentence.length > 96 ? sentence.slice(0, 93) + "\u2026" : sentence;
|
|
483
|
+
}
|
|
484
|
+
async function skillsInstallCommand(ctx) {
|
|
485
|
+
const available = await listSkills(ctx.skillsRoot);
|
|
486
|
+
if (available.length === 0) {
|
|
487
|
+
throw new CliError(EXIT.UNEXPECTED, "NO_SKILLS", "no skills are bundled with this build");
|
|
488
|
+
}
|
|
489
|
+
if (!ctx.all && ctx.names.length === 0) {
|
|
490
|
+
throw new CliError(
|
|
491
|
+
EXIT.USAGE,
|
|
492
|
+
"USAGE",
|
|
493
|
+
`name a skill or pass --all (available: ${available.map((s) => s.name).join(", ")})`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const wanted = ctx.all ? available.map((s) => s.name) : ctx.names;
|
|
497
|
+
const unknown = wanted.filter((name) => !available.some((s) => s.name === name));
|
|
498
|
+
if (unknown.length > 0) {
|
|
499
|
+
throw new CliError(
|
|
500
|
+
EXIT.NOT_FOUND,
|
|
501
|
+
"NOT_FOUND",
|
|
502
|
+
`unknown skill${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")} (available: ${available.map((s) => s.name).join(", ")})`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const destRoot = join2(ctx.targetDir, INSTALL_SUBPATH);
|
|
506
|
+
const installed = [];
|
|
507
|
+
for (const name of wanted) {
|
|
508
|
+
const dest = join2(destRoot, name);
|
|
509
|
+
const replaced = await exists(dest);
|
|
510
|
+
await cp(join2(ctx.skillsRoot, name), dest, { recursive: true, force: true });
|
|
511
|
+
installed.push({ name, path: dest, replaced });
|
|
512
|
+
}
|
|
513
|
+
if (ctx.json) {
|
|
514
|
+
ctx.out(JSON.stringify({ data: installed, count: installed.length }, null, 2));
|
|
515
|
+
return EXIT.OK;
|
|
516
|
+
}
|
|
517
|
+
const lines = [];
|
|
518
|
+
for (const item of installed) {
|
|
519
|
+
lines.push(`\u2713 ${item.name}${item.replaced ? " (updated)" : ""} \u2192 ${item.path}`);
|
|
520
|
+
}
|
|
521
|
+
lines.push("", "Restart Claude Code (or your agent) to pick them up.");
|
|
522
|
+
ctx.out(lines.join("\n") + "\n");
|
|
523
|
+
return EXIT.OK;
|
|
524
|
+
}
|
|
525
|
+
async function exists(path) {
|
|
526
|
+
try {
|
|
527
|
+
await stat(path);
|
|
528
|
+
return true;
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/prompt.ts
|
|
535
|
+
import { createInterface } from "readline";
|
|
536
|
+
function createDefaultPrompt() {
|
|
537
|
+
return ({ label, secret }) => new Promise((resolve) => {
|
|
538
|
+
const rl = createInterface({
|
|
539
|
+
input: process.stdin,
|
|
540
|
+
output: process.stdout,
|
|
541
|
+
terminal: true
|
|
542
|
+
});
|
|
543
|
+
if (secret) {
|
|
544
|
+
const mutable = rl;
|
|
545
|
+
mutable._writeToOutput = (s) => {
|
|
546
|
+
if (s.includes(label) || s === "\n" || s === "\r\n") process.stdout.write(s);
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
rl.question(`${label}: `, (answer) => {
|
|
550
|
+
rl.close();
|
|
551
|
+
if (secret) process.stdout.write("\n");
|
|
552
|
+
resolve(answer.trim());
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/run.ts
|
|
558
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
559
|
+
import { dirname as dirname2 } from "path";
|
|
560
|
+
|
|
561
|
+
// src/update.ts
|
|
562
|
+
import { spawn } from "child_process";
|
|
563
|
+
var MIN_NODE_MAJOR = 20;
|
|
564
|
+
function detectInstallMode(signals) {
|
|
565
|
+
const dir = signals.moduleDir;
|
|
566
|
+
if (dir.includes("/_npx/") || /(?:^|\s)npx\//.test(signals.userAgent ?? "")) {
|
|
567
|
+
return "npx";
|
|
568
|
+
}
|
|
569
|
+
const nm = dir.lastIndexOf("/node_modules/");
|
|
570
|
+
if (nm >= 0) {
|
|
571
|
+
const projectRoot = dir.slice(0, nm);
|
|
572
|
+
if (signals.cwd === projectRoot || signals.cwd.startsWith(projectRoot + "/")) {
|
|
573
|
+
return "local";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return "global";
|
|
577
|
+
}
|
|
578
|
+
function unsupportedNode(nodeVersion, min = MIN_NODE_MAJOR) {
|
|
579
|
+
const major = Number(nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
580
|
+
if (Number.isNaN(major)) return null;
|
|
581
|
+
if (major < min) {
|
|
582
|
+
return `financy requires Node ${min}+ (you have ${nodeVersion}). Please upgrade Node and try again.`;
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
var PACKAGE = "financy";
|
|
587
|
+
async function updateCommand(ctx) {
|
|
588
|
+
switch (ctx.mode) {
|
|
589
|
+
case "npx":
|
|
590
|
+
ctx.out("npx always runs the latest \u2014 nothing to update.\n");
|
|
591
|
+
return 0;
|
|
592
|
+
case "local":
|
|
593
|
+
ctx.out(
|
|
594
|
+
`financy is a project dependency \u2014 update it in your project (e.g. npm update ${PACKAGE}).
|
|
595
|
+
`
|
|
596
|
+
);
|
|
597
|
+
return 0;
|
|
598
|
+
case "global": {
|
|
599
|
+
ctx.out("Updating financy to the latest version\u2026\n");
|
|
600
|
+
const code = await ctx.exec("npm", ["install", "-g", `${PACKAGE}@latest`]);
|
|
601
|
+
if (code !== 0) {
|
|
602
|
+
ctx.err(`update failed \u2014 try running: npm install -g ${PACKAGE}@latest
|
|
603
|
+
`);
|
|
604
|
+
return 1;
|
|
605
|
+
}
|
|
606
|
+
ctx.out("financy is now up to date.\n");
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function spawnExec(cmd, args) {
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
614
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
615
|
+
child.on("error", () => resolve(1));
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/run.ts
|
|
620
|
+
async function run(argv, io = {}) {
|
|
621
|
+
const out = io.stdout ?? ((chunk) => void process.stdout.write(chunk));
|
|
622
|
+
const err = io.stderr ?? ((chunk) => void process.stderr.write(chunk));
|
|
623
|
+
const env = io.env ?? process.env;
|
|
624
|
+
const now = io.now ?? /* @__PURE__ */ new Date();
|
|
625
|
+
const prompt = io.prompt ?? createDefaultPrompt();
|
|
626
|
+
const json = argv.includes("--json");
|
|
627
|
+
const nodeError = unsupportedNode(io.nodeVersion ?? process.versions.node);
|
|
628
|
+
if (nodeError) {
|
|
629
|
+
err(`error: ${nodeError}
|
|
630
|
+
`);
|
|
631
|
+
return EXIT.UNEXPECTED;
|
|
632
|
+
}
|
|
633
|
+
const program = new Command();
|
|
634
|
+
program.name("financy").description("financy \u2014 your Open-Finance data, in the terminal").version(VERSION, "-v, --version", "print the version").configureOutput({ writeOut: out, writeErr: err }).exitOverride().showSuggestionAfterError(false);
|
|
635
|
+
let exitCode = EXIT.OK;
|
|
636
|
+
program.command("status").description("Are my connections fresh? One-line-per-bank rollup").option("--json", "machine-readable output (stable schema, errors as JSON on stderr)").action(async () => {
|
|
637
|
+
const result = await loadConfig(env);
|
|
638
|
+
if (!result.ok) {
|
|
639
|
+
throw new CliError(
|
|
640
|
+
EXIT.AUTH,
|
|
641
|
+
"NOT_CONFIGURED",
|
|
642
|
+
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
exitCode = await statusCommand({ config: result.config, json, now, out, err });
|
|
646
|
+
});
|
|
647
|
+
program.command("setup").description("Save your API credentials (Financy \u2192 Settings \u2192 API; requires a paid plan)").option("--no-input", "read credentials from FINANCY_* env vars (for agents/CI)").action(async (opts) => {
|
|
648
|
+
exitCode = await setupCommand({
|
|
649
|
+
env,
|
|
650
|
+
noInput: opts.input === false,
|
|
651
|
+
prompt,
|
|
652
|
+
out,
|
|
653
|
+
err
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
program.command("refresh").description("Trigger an on-demand refresh of all connections (20 credits)").option("--json", "machine-readable output").action(async () => {
|
|
657
|
+
const result = await loadConfig(env);
|
|
658
|
+
if (!result.ok) {
|
|
659
|
+
throw new CliError(
|
|
660
|
+
EXIT.AUTH,
|
|
661
|
+
"NOT_CONFIGURED",
|
|
662
|
+
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
exitCode = await refreshCommand({ config: result.config, json, now, out });
|
|
666
|
+
});
|
|
667
|
+
program.command("config").description("Show the resolved endpoints and credential sources (secret masked)").option("--json", "machine-readable output").action(async () => {
|
|
668
|
+
exitCode = await configCommand({ env, json, out });
|
|
669
|
+
});
|
|
670
|
+
program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
|
|
671
|
+
const { startMcpServer } = await import("./server-SIRWJMXP.js");
|
|
672
|
+
await startMcpServer(env);
|
|
673
|
+
});
|
|
674
|
+
const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
|
|
675
|
+
skills.command("list").description("List the bundled skills").option("--json", "machine-readable output").action(async () => {
|
|
676
|
+
exitCode = await skillsListCommand({
|
|
677
|
+
skillsRoot: io.skillsRoot ?? resolveSkillsRoot(),
|
|
678
|
+
targetDir: io.cwd ?? process.cwd(),
|
|
679
|
+
json,
|
|
680
|
+
out
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
skills.command("install").argument("[names...]", "skills to install (omit with --all for every skill)").description("Copy skills into this project's .claude/skills/ directory").option("--all", "install every bundled skill").option("--dir <path>", "install under this directory instead of the current one").option("--json", "machine-readable output").action(async (names, opts) => {
|
|
684
|
+
exitCode = await skillsInstallCommand({
|
|
685
|
+
skillsRoot: io.skillsRoot ?? resolveSkillsRoot(),
|
|
686
|
+
targetDir: opts.dir ?? io.cwd ?? process.cwd(),
|
|
687
|
+
names,
|
|
688
|
+
all: opts.all === true,
|
|
689
|
+
json,
|
|
690
|
+
out
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
program.command("update").description("Update the CLI to the latest version").action(async () => {
|
|
694
|
+
const mode = detectInstallMode({
|
|
695
|
+
moduleDir: dirname2(fileURLToPath2(import.meta.url)),
|
|
696
|
+
cwd: process.cwd(),
|
|
697
|
+
userAgent: env.npm_config_user_agent
|
|
698
|
+
});
|
|
699
|
+
exitCode = await updateCommand({ mode, exec: io.exec ?? spawnExec, out, err });
|
|
700
|
+
});
|
|
701
|
+
registerReadCommands(program, { env, now, json, out }, (code) => {
|
|
702
|
+
exitCode = code;
|
|
703
|
+
});
|
|
704
|
+
if (argv.length === 0) {
|
|
705
|
+
program.outputHelp();
|
|
706
|
+
return EXIT.OK;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await program.parseAsync(argv, { from: "user" });
|
|
710
|
+
return exitCode;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error instanceof CommanderError) {
|
|
713
|
+
return mapCommanderError(error);
|
|
714
|
+
}
|
|
715
|
+
return emitError({ err, json }, error);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function mapCommanderError(error) {
|
|
719
|
+
switch (error.code) {
|
|
720
|
+
case "commander.helpDisplayed":
|
|
721
|
+
case "commander.help":
|
|
722
|
+
case "commander.version":
|
|
723
|
+
return EXIT.OK;
|
|
724
|
+
default:
|
|
725
|
+
return EXIT.USAGE;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function emitError(io, error) {
|
|
729
|
+
const cliError = error instanceof CliError ? error : new CliError(EXIT.UNEXPECTED, "UNEXPECTED", String(error?.message ?? error));
|
|
730
|
+
if (io.json) {
|
|
731
|
+
io.err(
|
|
732
|
+
JSON.stringify({
|
|
733
|
+
error: { code: cliError.code, message: cliError.message, ...cliError.extra }
|
|
734
|
+
})
|
|
735
|
+
);
|
|
736
|
+
} else {
|
|
737
|
+
io.err(`error ${cliError.code}: ${cliError.message}
|
|
738
|
+
`);
|
|
739
|
+
}
|
|
740
|
+
return cliError.exitCode;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/bin.ts
|
|
744
|
+
run(process.argv.slice(2)).then((code) => {
|
|
745
|
+
process.exitCode = code;
|
|
746
|
+
}).catch((error) => {
|
|
747
|
+
process.stderr.write(`error UNEXPECTED: ${String(error?.message ?? error)}
|
|
748
|
+
`);
|
|
749
|
+
process.exitCode = 1;
|
|
750
|
+
});
|