financy 0.1.3 → 0.3.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/dist/bin.js +8 -5
- package/dist/{chunk-YOW6MO6F.js → chunk-VE5WZRGU.js} +32 -7
- package/dist/lib.d.ts +50 -0
- package/dist/lib.js +710 -0
- package/dist/{server-FZPZL65M.js → server-RM4BAVES.js} +20 -7
- package/package.json +11 -2
package/dist/bin.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
resolveEndpoints,
|
|
24
24
|
sanitizeCredential,
|
|
25
25
|
writeCredentialsFile
|
|
26
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-VE5WZRGU.js";
|
|
27
27
|
|
|
28
28
|
// src/run.ts
|
|
29
29
|
import { Command, CommanderError } from "commander";
|
|
@@ -346,7 +346,10 @@ var TRANSACTION_COLUMNS = [
|
|
|
346
346
|
{ header: "DESCRIPTION", value: (t) => dash(t.merchantName ?? t.description?.description) },
|
|
347
347
|
{ header: "CATEGORY", value: (t) => `${dash(t.category?.main)}/${dash(t.category?.sub)}` },
|
|
348
348
|
{ header: "TYPE", value: (t) => dash(t.type) },
|
|
349
|
-
|
|
349
|
+
// The SK, not the id: this column exists to be pasted into `transactions get`,
|
|
350
|
+
// and that route is keyed by SK. Matches accounts/connections, whose ID column
|
|
351
|
+
// likewise prints the value their `get` accepts.
|
|
352
|
+
{ header: "SK", value: (t) => t.SK ?? t.id }
|
|
350
353
|
];
|
|
351
354
|
var PROVIDER_COLUMNS = [
|
|
352
355
|
{ header: "ID", value: (p) => dash(p.providerFriendlyId ?? p.id) },
|
|
@@ -388,9 +391,9 @@ function registerReadCommands(program, base, setExit) {
|
|
|
388
391
|
const envelope = await fetchTransactions(config, base.now, opts);
|
|
389
392
|
setExit(renderList(ctx, envelope, TRANSACTION_COLUMNS));
|
|
390
393
|
});
|
|
391
|
-
transactions.command("get <
|
|
394
|
+
transactions.command("get <sk>").description("Read one transaction by its SK (the SK column of `transactions list`)").option("--json", "machine-readable output").action(async (sk) => {
|
|
392
395
|
const config = await loadOrThrow(base.env);
|
|
393
|
-
setExit(renderGet(ctx, await fetchTransaction(config, base.now,
|
|
396
|
+
setExit(renderGet(ctx, await fetchTransaction(config, base.now, sk)));
|
|
394
397
|
});
|
|
395
398
|
program.command("categories").description("The category taxonomy (English + Hebrew)").option("--json", "machine-readable output").action(async () => {
|
|
396
399
|
const config = await loadOrThrow(base.env);
|
|
@@ -737,7 +740,7 @@ async function run(argv, io = {}) {
|
|
|
737
740
|
exitCode = await configCommand({ env, json, out });
|
|
738
741
|
});
|
|
739
742
|
program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
|
|
740
|
-
const { startMcpServer } = await import("./server-
|
|
743
|
+
const { startMcpServer } = await import("./server-RM4BAVES.js");
|
|
741
744
|
await startMcpServer(env);
|
|
742
745
|
});
|
|
743
746
|
const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
|
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "financy",
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.3.0",
|
|
7
7
|
description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
|
|
8
8
|
type: "module",
|
|
9
9
|
bin: {
|
|
10
10
|
financy: "dist/bin.js"
|
|
11
11
|
},
|
|
12
|
+
types: "./dist/lib.d.ts",
|
|
13
|
+
exports: {
|
|
14
|
+
".": {
|
|
15
|
+
types: "./dist/lib.d.ts",
|
|
16
|
+
import: "./dist/lib.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
12
20
|
license: "Apache-2.0",
|
|
13
21
|
author: "Open Finance AI <hello@open-finance.ai>",
|
|
14
22
|
homepage: "https://github.com/open-finance-ai/financy#readme",
|
|
@@ -42,6 +50,7 @@ var package_default = {
|
|
|
42
50
|
node: ">=20"
|
|
43
51
|
},
|
|
44
52
|
scripts: {
|
|
53
|
+
prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
|
|
45
54
|
build: "tsup",
|
|
46
55
|
test: "vitest run",
|
|
47
56
|
"test:watch": "vitest",
|
|
@@ -312,6 +321,7 @@ async function getAccessToken(config, now) {
|
|
|
312
321
|
return mintAndCache(config);
|
|
313
322
|
}
|
|
314
323
|
async function authorized(config, now, request) {
|
|
324
|
+
if (config.bearerToken) return request(config.bearerToken);
|
|
315
325
|
const token = await getAccessToken(config, now);
|
|
316
326
|
try {
|
|
317
327
|
return await request(token);
|
|
@@ -351,7 +361,10 @@ async function getPage(config, token, path, query = {}) {
|
|
|
351
361
|
for (const [key, value] of Object.entries(query)) {
|
|
352
362
|
if (value !== void 0) url.searchParams.set(key, value);
|
|
353
363
|
}
|
|
354
|
-
const res = await httpFetch(url.toString(), {
|
|
364
|
+
const res = await httpFetch(url.toString(), {
|
|
365
|
+
headers: authHeaders(token),
|
|
366
|
+
signal: config.abortSignal
|
|
367
|
+
});
|
|
355
368
|
await ensureOk(res);
|
|
356
369
|
const raw = await res.json().catch(() => ({}));
|
|
357
370
|
debugBody(path, raw);
|
|
@@ -364,7 +377,10 @@ function normalizePage(raw) {
|
|
|
364
377
|
return { items, nextPage: obj.nextPage ?? null, count: obj.count ?? items.length };
|
|
365
378
|
}
|
|
366
379
|
async function getById(config, token, path) {
|
|
367
|
-
const res = await httpFetch(config.apiBaseUrl + path, {
|
|
380
|
+
const res = await httpFetch(config.apiBaseUrl + path, {
|
|
381
|
+
headers: authHeaders(token),
|
|
382
|
+
signal: config.abortSignal
|
|
383
|
+
});
|
|
368
384
|
if (res.status === 404) return null;
|
|
369
385
|
await ensureOk(res);
|
|
370
386
|
const body = await res.json();
|
|
@@ -375,7 +391,8 @@ async function getById(config, token, path) {
|
|
|
375
391
|
async function postRefresh(config, token) {
|
|
376
392
|
const res = await httpFetch(config.chatBaseUrl + "/connections/refresh", {
|
|
377
393
|
method: "POST",
|
|
378
|
-
headers: authHeaders(token)
|
|
394
|
+
headers: authHeaders(token),
|
|
395
|
+
signal: config.abortSignal
|
|
379
396
|
});
|
|
380
397
|
if (res.status === 401) throw authFailed();
|
|
381
398
|
if (res.status === 402) {
|
|
@@ -499,12 +516,20 @@ function fetchTransactions(config, now, args) {
|
|
|
499
516
|
!!args.all
|
|
500
517
|
);
|
|
501
518
|
}
|
|
502
|
-
|
|
519
|
+
var BARE_TRANSACTION_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
520
|
+
function fetchTransaction(config, now, key) {
|
|
521
|
+
if (BARE_TRANSACTION_ID.test(key)) {
|
|
522
|
+
throw new CliError(
|
|
523
|
+
EXIT.USAGE,
|
|
524
|
+
"INVALID_ARGUMENT",
|
|
525
|
+
`'${key}' is a transaction's \`id\`, but this lookup is keyed by its \`SK\` \u2014 use the \`SK\` field from the same transaction (it looks like "TRANSACTION#TYPE#\u2026#${key}")`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
503
528
|
return getOne(
|
|
504
529
|
config,
|
|
505
530
|
now,
|
|
506
|
-
`/data/transactions/${encodeURIComponent(
|
|
507
|
-
notFound("TRANSACTION_NOT_FOUND", `no transaction ${
|
|
531
|
+
`/data/transactions/${encodeURIComponent(key)}`,
|
|
532
|
+
notFound("TRANSACTION_NOT_FOUND", `no transaction ${key} in your scope`)
|
|
508
533
|
);
|
|
509
534
|
}
|
|
510
535
|
function fetchCategories(config, now) {
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface Credentials {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
}
|
|
6
|
+
interface Endpoints {
|
|
7
|
+
authTokenUrl: string;
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
chatBaseUrl: string;
|
|
10
|
+
configDir: string;
|
|
11
|
+
}
|
|
12
|
+
type Config = Credentials & Endpoints & {
|
|
13
|
+
bearerToken?: string;
|
|
14
|
+
abortSignal?: AbortSignal;
|
|
15
|
+
};
|
|
16
|
+
declare function bearerConfig(env: NodeJS.ProcessEnv, bearerToken: string, abortSignal?: AbortSignal): Config;
|
|
17
|
+
|
|
18
|
+
/** JSON-Schema fragment for a tool's arguments (kept plain so tests need no SDK/zod). */
|
|
19
|
+
type JsonSchema = {
|
|
20
|
+
type: 'object';
|
|
21
|
+
properties: Record<string, unknown>;
|
|
22
|
+
required?: string[];
|
|
23
|
+
};
|
|
24
|
+
interface ToolContext {
|
|
25
|
+
config: Config;
|
|
26
|
+
now: Date;
|
|
27
|
+
}
|
|
28
|
+
interface ToolDef {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
inputSchema: JsonSchema;
|
|
32
|
+
run: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
declare const TOOLS: ToolDef[];
|
|
35
|
+
interface CallToolIO {
|
|
36
|
+
env: NodeJS.ProcessEnv;
|
|
37
|
+
now?: Date;
|
|
38
|
+
config?: Config;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Dispatch a tool by name: resolve config (unconfigured → the structured
|
|
42
|
+
* NOT_CONFIGURED error, on every tool), run it, and return its envelope. Errors
|
|
43
|
+
* are returned as the `{error:{code,message}}` envelope rather than thrown, so
|
|
44
|
+
* the server surfaces them to the agent as machine-readable tool output.
|
|
45
|
+
*/
|
|
46
|
+
declare function callTool(name: string, args: Record<string, unknown>, io: CallToolIO): Promise<unknown>;
|
|
47
|
+
|
|
48
|
+
declare const VERSION: string;
|
|
49
|
+
|
|
50
|
+
export { type CallToolIO, type Config, type JsonSchema, TOOLS, type ToolContext, type ToolDef, VERSION, bearerConfig, callTool };
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
// src/credentials.ts
|
|
7
|
+
function isControl(ch) {
|
|
8
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
9
|
+
return code < 32 || code === 127;
|
|
10
|
+
}
|
|
11
|
+
function sanitizeCredential(raw) {
|
|
12
|
+
const stripped = Array.from(raw).filter((ch) => !isControl(ch)).join("").trim();
|
|
13
|
+
const quoted = /^(["'])(.*)\1$/.exec(stripped);
|
|
14
|
+
return (quoted?.[2] ?? stripped).trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/exit-codes.ts
|
|
18
|
+
var EXIT = {
|
|
19
|
+
OK: 0,
|
|
20
|
+
UNEXPECTED: 1,
|
|
21
|
+
USAGE: 2,
|
|
22
|
+
AUTH: 3,
|
|
23
|
+
PLAN: 4,
|
|
24
|
+
CREDITS: 5,
|
|
25
|
+
NOT_FOUND: 6,
|
|
26
|
+
API: 7
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/errors.ts
|
|
30
|
+
var CliError = class extends Error {
|
|
31
|
+
code;
|
|
32
|
+
exitCode;
|
|
33
|
+
extra;
|
|
34
|
+
constructor(exitCode, code, message, extra = {}) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "CliError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.exitCode = exitCode;
|
|
39
|
+
this.extra = extra;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
|
|
43
|
+
var planNotEligible = () => new CliError(
|
|
44
|
+
EXIT.PLAN,
|
|
45
|
+
"NOT_AVAILABLE_ON_PLAN",
|
|
46
|
+
"the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
|
|
47
|
+
);
|
|
48
|
+
var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
|
|
49
|
+
var insufficientCredits = (cost, balance) => new CliError(
|
|
50
|
+
EXIT.CREDITS,
|
|
51
|
+
"INSUFFICIENT_CREDITS",
|
|
52
|
+
`an initiated refresh costs ${cost} credits; you have ${balance}`,
|
|
53
|
+
{ cost, balance }
|
|
54
|
+
);
|
|
55
|
+
var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
|
|
56
|
+
var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
|
|
57
|
+
|
|
58
|
+
// src/config.ts
|
|
59
|
+
var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
|
|
60
|
+
var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
|
|
61
|
+
var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
|
|
62
|
+
function resolveConfigDir(env) {
|
|
63
|
+
if (env.FINANCY_CONFIG_DIR) return env.FINANCY_CONFIG_DIR;
|
|
64
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
65
|
+
return join(base, "financy");
|
|
66
|
+
}
|
|
67
|
+
function resolveEndpoints(env) {
|
|
68
|
+
return {
|
|
69
|
+
authTokenUrl: env.FINANCY_AUTH_URL ?? DEFAULT_AUTH_TOKEN_URL,
|
|
70
|
+
apiBaseUrl: env.FINANCY_API_URL ?? DEFAULT_API_BASE_URL,
|
|
71
|
+
chatBaseUrl: env.FINANCY_CHAT_URL ?? DEFAULT_CHAT_BASE_URL,
|
|
72
|
+
configDir: resolveConfigDir(env)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function configFile(configDir) {
|
|
76
|
+
return join(configDir, "config.json");
|
|
77
|
+
}
|
|
78
|
+
function decodeConfigBytes(bytes) {
|
|
79
|
+
if (bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
|
|
80
|
+
if (bytes[0] === 254 && bytes[1] === 255) {
|
|
81
|
+
return Buffer.from(bytes.subarray(2)).swap16().toString("utf16le");
|
|
82
|
+
}
|
|
83
|
+
if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
|
|
84
|
+
return bytes.subarray(3).toString("utf8");
|
|
85
|
+
}
|
|
86
|
+
return bytes.toString("utf8");
|
|
87
|
+
}
|
|
88
|
+
async function readCredentialsFile(configDir) {
|
|
89
|
+
let bytes;
|
|
90
|
+
try {
|
|
91
|
+
bytes = await readFile(configFile(configDir));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const code = error.code;
|
|
94
|
+
if (code === "ENOENT") return { status: "missing" };
|
|
95
|
+
return { status: "unreadable", detail: code ?? String(error) };
|
|
96
|
+
}
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(decodeConfigBytes(bytes));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { status: "malformed", detail: `not valid JSON (${error.message})` };
|
|
102
|
+
}
|
|
103
|
+
const credentials = parsed?.profiles?.default;
|
|
104
|
+
if (!credentials || typeof credentials !== "object") {
|
|
105
|
+
return {
|
|
106
|
+
status: "malformed",
|
|
107
|
+
detail: 'no "profiles.default" object \u2014 expected {"profiles":{"default":{\u2026}}}'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { status: "ok", credentials };
|
|
111
|
+
}
|
|
112
|
+
function bearerConfig(env, bearerToken, abortSignal) {
|
|
113
|
+
return {
|
|
114
|
+
...resolveEndpoints(env),
|
|
115
|
+
clientId: "",
|
|
116
|
+
clientSecret: "",
|
|
117
|
+
userId: "",
|
|
118
|
+
bearerToken,
|
|
119
|
+
abortSignal
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function pick(envValue, fileValue) {
|
|
123
|
+
const raw = envValue ?? fileValue;
|
|
124
|
+
if (raw === void 0) return void 0;
|
|
125
|
+
const value = sanitizeCredential(raw);
|
|
126
|
+
return value === "" ? void 0 : value;
|
|
127
|
+
}
|
|
128
|
+
async function loadConfig(env) {
|
|
129
|
+
const endpoints = resolveEndpoints(env);
|
|
130
|
+
const result = await readCredentialsFile(endpoints.configDir);
|
|
131
|
+
const file = result.status === "ok" ? result.credentials : {};
|
|
132
|
+
const fileProblem = result.status === "missing" || result.status === "ok" ? void 0 : `${configFile(endpoints.configDir)} could not be read: ${result.detail}`;
|
|
133
|
+
const clientId = pick(env.FINANCY_CLIENT_ID, file.clientId);
|
|
134
|
+
const clientSecret = pick(env.FINANCY_CLIENT_SECRET, file.clientSecret);
|
|
135
|
+
const userId = pick(env.FINANCY_USER_ID, file.userId);
|
|
136
|
+
const missing = [];
|
|
137
|
+
if (!clientId) missing.push("FINANCY_CLIENT_ID");
|
|
138
|
+
if (!clientSecret) missing.push("FINANCY_CLIENT_SECRET");
|
|
139
|
+
if (!userId) missing.push("FINANCY_USER_ID");
|
|
140
|
+
if (!clientId || !clientSecret || !userId) return { ok: false, missing, fileProblem };
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
config: { clientId, clientSecret, userId, ...endpoints }
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function notConfigured(result) {
|
|
147
|
+
const suffix = result.fileProblem ? ` \u2014 ${result.fileProblem}` : "";
|
|
148
|
+
return new CliError(
|
|
149
|
+
EXIT.AUTH,
|
|
150
|
+
"NOT_CONFIGURED",
|
|
151
|
+
`missing ${result.missing.join(", ")} \u2014 run financy setup${suffix}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// package.json
|
|
156
|
+
var package_default = {
|
|
157
|
+
name: "financy",
|
|
158
|
+
version: "0.3.0",
|
|
159
|
+
description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
|
|
160
|
+
type: "module",
|
|
161
|
+
bin: {
|
|
162
|
+
financy: "dist/bin.js"
|
|
163
|
+
},
|
|
164
|
+
types: "./dist/lib.d.ts",
|
|
165
|
+
exports: {
|
|
166
|
+
".": {
|
|
167
|
+
types: "./dist/lib.d.ts",
|
|
168
|
+
import: "./dist/lib.js"
|
|
169
|
+
},
|
|
170
|
+
"./package.json": "./package.json"
|
|
171
|
+
},
|
|
172
|
+
license: "Apache-2.0",
|
|
173
|
+
author: "Open Finance AI <hello@open-finance.ai>",
|
|
174
|
+
homepage: "https://github.com/open-finance-ai/financy#readme",
|
|
175
|
+
bugs: {
|
|
176
|
+
url: "https://github.com/open-finance-ai/financy/issues"
|
|
177
|
+
},
|
|
178
|
+
keywords: [
|
|
179
|
+
"financy",
|
|
180
|
+
"open-finance",
|
|
181
|
+
"open-banking",
|
|
182
|
+
"cli",
|
|
183
|
+
"mcp",
|
|
184
|
+
"claude-code",
|
|
185
|
+
"agent-skills",
|
|
186
|
+
"fintech",
|
|
187
|
+
"israel"
|
|
188
|
+
],
|
|
189
|
+
repository: {
|
|
190
|
+
type: "git",
|
|
191
|
+
url: "git+https://github.com/open-finance-ai/financy.git"
|
|
192
|
+
},
|
|
193
|
+
publishConfig: {
|
|
194
|
+
access: "public",
|
|
195
|
+
provenance: true
|
|
196
|
+
},
|
|
197
|
+
files: [
|
|
198
|
+
"dist",
|
|
199
|
+
"skills"
|
|
200
|
+
],
|
|
201
|
+
engines: {
|
|
202
|
+
node: ">=20"
|
|
203
|
+
},
|
|
204
|
+
scripts: {
|
|
205
|
+
prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
|
|
206
|
+
build: "tsup",
|
|
207
|
+
test: "vitest run",
|
|
208
|
+
"test:watch": "vitest",
|
|
209
|
+
typecheck: "tsc --noEmit",
|
|
210
|
+
lint: "eslint .",
|
|
211
|
+
smoke: "node scripts/smoke.mjs",
|
|
212
|
+
prepublishOnly: "npm run build"
|
|
213
|
+
},
|
|
214
|
+
dependencies: {
|
|
215
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
216
|
+
commander: "^12.1.0"
|
|
217
|
+
},
|
|
218
|
+
devDependencies: {
|
|
219
|
+
"@eslint/js": "^10.0.1",
|
|
220
|
+
"@types/node": "^20.14.0",
|
|
221
|
+
eslint: "^10.7.0",
|
|
222
|
+
globals: "^17.7.0",
|
|
223
|
+
tsup: "^8.2.4",
|
|
224
|
+
typescript: "^5.5.4",
|
|
225
|
+
"typescript-eslint": "^8.65.0",
|
|
226
|
+
undici: "^6.19.8",
|
|
227
|
+
vitest: "^4.1.10"
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/version.ts
|
|
232
|
+
var VERSION = package_default.version;
|
|
233
|
+
var USER_AGENT = `financy-cli/${VERSION}`;
|
|
234
|
+
|
|
235
|
+
// src/http.ts
|
|
236
|
+
async function httpFetch(url, init) {
|
|
237
|
+
try {
|
|
238
|
+
return await fetch(url, init);
|
|
239
|
+
} catch {
|
|
240
|
+
throw apiUnavailable();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/token-store.ts
|
|
245
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
246
|
+
import { join as join2 } from "path";
|
|
247
|
+
function jwtExp(token) {
|
|
248
|
+
const payload = token.split(".")[1];
|
|
249
|
+
if (!payload) return null;
|
|
250
|
+
try {
|
|
251
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
252
|
+
return typeof decoded.exp === "number" ? decoded.exp : null;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function tokenFile(configDir) {
|
|
258
|
+
return join2(configDir, "token.json");
|
|
259
|
+
}
|
|
260
|
+
async function readCachedToken(configDir) {
|
|
261
|
+
try {
|
|
262
|
+
const raw = await readFile2(tokenFile(configDir), "utf8");
|
|
263
|
+
const parsed = JSON.parse(raw);
|
|
264
|
+
if (typeof parsed.accessToken === "string" && typeof parsed.exp === "number") {
|
|
265
|
+
return { accessToken: parsed.accessToken, exp: parsed.exp };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function writeCachedToken(configDir, token) {
|
|
273
|
+
await mkdir2(configDir, { recursive: true, mode: 448 });
|
|
274
|
+
await writeFile2(tokenFile(configDir), JSON.stringify(token), { mode: 384 });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/auth.ts
|
|
278
|
+
var EXPIRY_SKEW_SECONDS = 30;
|
|
279
|
+
async function mintToken(config) {
|
|
280
|
+
const res = await httpFetch(config.authTokenUrl, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: {
|
|
283
|
+
"content-type": "application/json",
|
|
284
|
+
"user-agent": USER_AGENT
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
userId: config.userId,
|
|
288
|
+
clientId: config.clientId,
|
|
289
|
+
clientSecret: config.clientSecret
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
const detail = (await res.text().catch(() => "")).slice(0, 400);
|
|
294
|
+
throw authFailed(
|
|
295
|
+
`token endpoint ${config.authTokenUrl} returned HTTP ${res.status}` + (detail ? `: ${detail}` : "") + " \u2014 check credentials, or set FINANCY_AUTH_URL if the endpoint differs"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const body = await res.json().catch(() => ({}));
|
|
299
|
+
if (!body.accessToken) {
|
|
300
|
+
const keys = body && typeof body === "object" ? Object.keys(body) : [];
|
|
301
|
+
throw authFailed(
|
|
302
|
+
`token endpoint returned 200 but no "accessToken" field; response keys: [${keys.join(", ")}]`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return body.accessToken;
|
|
306
|
+
}
|
|
307
|
+
async function mintAndCache(config) {
|
|
308
|
+
const accessToken = await mintToken(config);
|
|
309
|
+
const exp = jwtExp(accessToken);
|
|
310
|
+
if (exp !== null) {
|
|
311
|
+
await writeCachedToken(config.configDir, { accessToken, exp });
|
|
312
|
+
}
|
|
313
|
+
return accessToken;
|
|
314
|
+
}
|
|
315
|
+
async function getAccessToken(config, now) {
|
|
316
|
+
const cached = await readCachedToken(config.configDir);
|
|
317
|
+
const nowSeconds = Math.floor(now.getTime() / 1e3);
|
|
318
|
+
if (cached && cached.exp - EXPIRY_SKEW_SECONDS > nowSeconds) {
|
|
319
|
+
return cached.accessToken;
|
|
320
|
+
}
|
|
321
|
+
return mintAndCache(config);
|
|
322
|
+
}
|
|
323
|
+
async function authorized(config, now, request) {
|
|
324
|
+
if (config.bearerToken) return request(config.bearerToken);
|
|
325
|
+
const token = await getAccessToken(config, now);
|
|
326
|
+
try {
|
|
327
|
+
return await request(token);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error instanceof CliError && error.code === "AUTH_FAILED") {
|
|
330
|
+
return await request(await mintAndCache(config));
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/api.ts
|
|
337
|
+
function authHeaders(token) {
|
|
338
|
+
return { authorization: `Bearer ${token}`, "user-agent": USER_AGENT };
|
|
339
|
+
}
|
|
340
|
+
async function ensureOk(res) {
|
|
341
|
+
if (res.ok) return;
|
|
342
|
+
if (res.status === 401) throw authFailed();
|
|
343
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
344
|
+
if (res.status === 403) {
|
|
345
|
+
if (!detail || /NOT_AVAILABLE_ON_PLAN|plan/i.test(detail)) throw planNotEligible();
|
|
346
|
+
throw forbidden(detail);
|
|
347
|
+
}
|
|
348
|
+
throw apiUnavailable(
|
|
349
|
+
`the API returned HTTP ${res.status}${detail ? `: ${detail}` : ""}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
function debugBody(path, body) {
|
|
353
|
+
if (process.env.FINANCY_DEBUG) {
|
|
354
|
+
process.stderr.write(`[financy:debug] ${path} \u2192
|
|
355
|
+
${JSON.stringify(body, null, 2)}
|
|
356
|
+
`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function getPage(config, token, path, query = {}) {
|
|
360
|
+
const url = new URL(config.apiBaseUrl + path);
|
|
361
|
+
for (const [key, value] of Object.entries(query)) {
|
|
362
|
+
if (value !== void 0) url.searchParams.set(key, value);
|
|
363
|
+
}
|
|
364
|
+
const res = await httpFetch(url.toString(), {
|
|
365
|
+
headers: authHeaders(token),
|
|
366
|
+
signal: config.abortSignal
|
|
367
|
+
});
|
|
368
|
+
await ensureOk(res);
|
|
369
|
+
const raw = await res.json().catch(() => ({}));
|
|
370
|
+
debugBody(path, raw);
|
|
371
|
+
return normalizePage(raw);
|
|
372
|
+
}
|
|
373
|
+
function normalizePage(raw) {
|
|
374
|
+
if (Array.isArray(raw)) return { items: raw, nextPage: null, count: raw.length };
|
|
375
|
+
const obj = raw ?? {};
|
|
376
|
+
const items = Array.isArray(obj.items) ? obj.items : [];
|
|
377
|
+
return { items, nextPage: obj.nextPage ?? null, count: obj.count ?? items.length };
|
|
378
|
+
}
|
|
379
|
+
async function getById(config, token, path) {
|
|
380
|
+
const res = await httpFetch(config.apiBaseUrl + path, {
|
|
381
|
+
headers: authHeaders(token),
|
|
382
|
+
signal: config.abortSignal
|
|
383
|
+
});
|
|
384
|
+
if (res.status === 404) return null;
|
|
385
|
+
await ensureOk(res);
|
|
386
|
+
const body = await res.json();
|
|
387
|
+
debugBody(path, body);
|
|
388
|
+
if (body && typeof body === "object" && Object.keys(body).length === 0) return null;
|
|
389
|
+
return body;
|
|
390
|
+
}
|
|
391
|
+
async function postRefresh(config, token) {
|
|
392
|
+
const res = await httpFetch(config.chatBaseUrl + "/connections/refresh", {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: authHeaders(token),
|
|
395
|
+
signal: config.abortSignal
|
|
396
|
+
});
|
|
397
|
+
if (res.status === 401) throw authFailed();
|
|
398
|
+
if (res.status === 402) {
|
|
399
|
+
const body = await res.json().catch(() => ({}));
|
|
400
|
+
throw insufficientCredits(body.cost ?? 20, body.balance ?? 0);
|
|
401
|
+
}
|
|
402
|
+
if (res.status === 409) {
|
|
403
|
+
const body = await res.json().catch(() => ({}));
|
|
404
|
+
throw noRefreshableConnections(body.message);
|
|
405
|
+
}
|
|
406
|
+
await ensureOk(res);
|
|
407
|
+
return await res.json();
|
|
408
|
+
}
|
|
409
|
+
async function getConnections(config, token) {
|
|
410
|
+
return getPage(config, token, "/connections");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/core/read.ts
|
|
414
|
+
var upper = (v) => v === void 0 ? void 0 : v.toUpperCase();
|
|
415
|
+
async function fetchPage(config, now, path, query) {
|
|
416
|
+
return authorized(config, now, (token) => getPage(config, token, path, query));
|
|
417
|
+
}
|
|
418
|
+
async function collectAll(config, now, path, query) {
|
|
419
|
+
const data = [];
|
|
420
|
+
let cursor = query.nextPage;
|
|
421
|
+
do {
|
|
422
|
+
const page = await fetchPage(config, now, path, { ...query, nextPage: cursor });
|
|
423
|
+
data.push(...page.items);
|
|
424
|
+
cursor = page.nextPage ?? void 0;
|
|
425
|
+
} while (cursor);
|
|
426
|
+
return { data, nextPage: null, count: data.length };
|
|
427
|
+
}
|
|
428
|
+
async function list(config, now, path, query, all) {
|
|
429
|
+
if (all) return collectAll(config, now, path, query);
|
|
430
|
+
const page = await fetchPage(config, now, path, query);
|
|
431
|
+
return { data: page.items, count: page.count, nextPage: page.nextPage };
|
|
432
|
+
}
|
|
433
|
+
async function getOne(config, now, path, notFound2) {
|
|
434
|
+
const resource = await authorized(
|
|
435
|
+
config,
|
|
436
|
+
now,
|
|
437
|
+
(token) => getById(config, token, path)
|
|
438
|
+
);
|
|
439
|
+
if (resource === null) throw notFound2;
|
|
440
|
+
return { data: resource };
|
|
441
|
+
}
|
|
442
|
+
var notFound = (code, message) => new CliError(EXIT.NOT_FOUND, code, message);
|
|
443
|
+
function fetchConnections(config, now, args) {
|
|
444
|
+
return list(config, now, "/connections", { limit: args.limit, nextPage: args.cursor }, !!args.all);
|
|
445
|
+
}
|
|
446
|
+
function fetchConnection(config, now, id) {
|
|
447
|
+
return getOne(
|
|
448
|
+
config,
|
|
449
|
+
now,
|
|
450
|
+
`/connections/${encodeURIComponent(id)}`,
|
|
451
|
+
notFound("CONNECTION_NOT_FOUND", `no connection ${id} in your scope`)
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
function fetchAccounts(config, now, args) {
|
|
455
|
+
return list(
|
|
456
|
+
config,
|
|
457
|
+
now,
|
|
458
|
+
"/data/accounts",
|
|
459
|
+
{
|
|
460
|
+
accountType: upper(args.type),
|
|
461
|
+
connectionId: args.connection,
|
|
462
|
+
limit: args.limit,
|
|
463
|
+
nextPage: args.cursor
|
|
464
|
+
},
|
|
465
|
+
!!args.all
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
function fetchAccount(config, now, id) {
|
|
469
|
+
return getOne(
|
|
470
|
+
config,
|
|
471
|
+
now,
|
|
472
|
+
`/data/accounts/${encodeURIComponent(id)}`,
|
|
473
|
+
notFound("ACCOUNT_NOT_FOUND", `no account ${id} in your scope`)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
function fetchTransactions(config, now, args) {
|
|
477
|
+
return list(
|
|
478
|
+
config,
|
|
479
|
+
now,
|
|
480
|
+
"/data/transactions",
|
|
481
|
+
{
|
|
482
|
+
dateFrom: args.from,
|
|
483
|
+
dateTo: args.to,
|
|
484
|
+
accountId: args.account,
|
|
485
|
+
connectionId: args.connection,
|
|
486
|
+
type: upper(args.type),
|
|
487
|
+
limit: args.limit,
|
|
488
|
+
nextPage: args.cursor
|
|
489
|
+
},
|
|
490
|
+
!!args.all
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
var BARE_TRANSACTION_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
494
|
+
function fetchTransaction(config, now, key) {
|
|
495
|
+
if (BARE_TRANSACTION_ID.test(key)) {
|
|
496
|
+
throw new CliError(
|
|
497
|
+
EXIT.USAGE,
|
|
498
|
+
"INVALID_ARGUMENT",
|
|
499
|
+
`'${key}' is a transaction's \`id\`, but this lookup is keyed by its \`SK\` \u2014 use the \`SK\` field from the same transaction (it looks like "TRANSACTION#TYPE#\u2026#${key}")`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return getOne(
|
|
503
|
+
config,
|
|
504
|
+
now,
|
|
505
|
+
`/data/transactions/${encodeURIComponent(key)}`,
|
|
506
|
+
notFound("TRANSACTION_NOT_FOUND", `no transaction ${key} in your scope`)
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
function fetchCategories(config, now) {
|
|
510
|
+
return authorized(
|
|
511
|
+
config,
|
|
512
|
+
now,
|
|
513
|
+
(token) => getById(config, token, "/data/transaction-categories")
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
function fetchProviders(config, now) {
|
|
517
|
+
return list(config, now, "/providers", {}, false);
|
|
518
|
+
}
|
|
519
|
+
function fetchBranches(config, now) {
|
|
520
|
+
return list(config, now, "/bank-branches", {}, false);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/core/status.ts
|
|
524
|
+
var STALE_THRESHOLD_DAYS = 2;
|
|
525
|
+
function toRow(c) {
|
|
526
|
+
return {
|
|
527
|
+
provider: c.providerId ?? null,
|
|
528
|
+
status: c.status,
|
|
529
|
+
fresh: c.lastFetchedDataDate ?? null,
|
|
530
|
+
expires: c.expiryDate ?? null,
|
|
531
|
+
accounts: (c.accounts ?? 0) + (c.cards ?? 0) + (c.savings ?? 0) + (c.loans ?? 0) + (c.securities ?? 0)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function fetchStatus(config, now) {
|
|
535
|
+
const { items } = await authorized(config, now, (token) => getConnections(config, token));
|
|
536
|
+
return {
|
|
537
|
+
data: items.map(toRow),
|
|
538
|
+
staleThresholdDays: STALE_THRESHOLD_DAYS,
|
|
539
|
+
connections: items
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/core/refresh.ts
|
|
544
|
+
async function fetchRefresh(config, now) {
|
|
545
|
+
const result = await authorized(config, now, (token) => postRefresh(config, token));
|
|
546
|
+
return { data: result };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/mcp/tools.ts
|
|
550
|
+
var str = (v) => v == null ? void 0 : String(v);
|
|
551
|
+
var bool = (v) => v === true;
|
|
552
|
+
function requireId(args) {
|
|
553
|
+
const id = args.id;
|
|
554
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
555
|
+
throw new CliError(EXIT.USAGE, "MISSING_ARGUMENT", "the `id` argument is required");
|
|
556
|
+
}
|
|
557
|
+
return id;
|
|
558
|
+
}
|
|
559
|
+
var PAGING = {
|
|
560
|
+
limit: { type: "number", description: "page size (defaults to the API default)" },
|
|
561
|
+
cursor: { type: "string", description: "opaque nextPage cursor from a prior call" },
|
|
562
|
+
all: { type: "boolean", description: "auto-paginate to the end (ignores cursor/limit paging)" }
|
|
563
|
+
};
|
|
564
|
+
var NO_ARGS = { type: "object", properties: {} };
|
|
565
|
+
var idSchema = (noun) => ({
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: { id: { type: "string", description: `the ${noun} id` } },
|
|
568
|
+
required: ["id"]
|
|
569
|
+
});
|
|
570
|
+
var listArgs = (a) => ({
|
|
571
|
+
limit: str(a.limit),
|
|
572
|
+
cursor: str(a.cursor),
|
|
573
|
+
all: bool(a.all)
|
|
574
|
+
});
|
|
575
|
+
var TOOLS = [
|
|
576
|
+
{
|
|
577
|
+
name: "list_connections",
|
|
578
|
+
description: "List the user's bank/card connections and their fetch state (status, data-through date, consent expiry, per-type counts). Start here to see what is connected. Supports limit/cursor paging and all.",
|
|
579
|
+
inputSchema: { type: "object", properties: { ...PAGING } },
|
|
580
|
+
run: (a, ctx) => fetchConnections(ctx.config, ctx.now, listArgs(a))
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
name: "get_connection",
|
|
584
|
+
description: "Read a single connection by id (from list_connections). Errors CONNECTION_NOT_FOUND if it is not in the user's scope.",
|
|
585
|
+
inputSchema: idSchema("connection"),
|
|
586
|
+
run: (a, ctx) => fetchConnection(ctx.config, ctx.now, requireId(a))
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: "list_accounts",
|
|
590
|
+
description: "List accounts with balances (securities embedded). Filter by type (CHECKING|CARD|LOAN|SAVINGS|SECURITY) or connection. Supports limit/cursor/all.",
|
|
591
|
+
inputSchema: {
|
|
592
|
+
type: "object",
|
|
593
|
+
properties: {
|
|
594
|
+
type: { type: "string", description: "CHECKING|CARD|LOAN|SAVINGS|SECURITY" },
|
|
595
|
+
connection: { type: "string", description: "filter to one connection id" },
|
|
596
|
+
...PAGING
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
run: (a, ctx) => fetchAccounts(ctx.config, ctx.now, {
|
|
600
|
+
...listArgs(a),
|
|
601
|
+
type: str(a.type),
|
|
602
|
+
connection: str(a.connection)
|
|
603
|
+
})
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: "get_account",
|
|
607
|
+
description: "Read a single account by id (from list_accounts). Errors ACCOUNT_NOT_FOUND if not in scope.",
|
|
608
|
+
inputSchema: idSchema("account"),
|
|
609
|
+
run: (a, ctx) => fetchAccount(ctx.config, ctx.now, requireId(a))
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "list_transactions",
|
|
613
|
+
description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is. To then read one transaction, pass its `SK` (not its `id`) to get_transaction.",
|
|
614
|
+
inputSchema: {
|
|
615
|
+
type: "object",
|
|
616
|
+
properties: {
|
|
617
|
+
from: { type: "string", description: "earliest transaction date (YYYY-MM-DD)" },
|
|
618
|
+
to: { type: "string", description: "latest transaction date (YYYY-MM-DD)" },
|
|
619
|
+
account: { type: "string", description: "filter to one account id" },
|
|
620
|
+
connection: { type: "string", description: "filter to one connection id" },
|
|
621
|
+
type: { type: "string", description: "BANK|CARD" },
|
|
622
|
+
...PAGING
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
run: (a, ctx) => fetchTransactions(ctx.config, ctx.now, {
|
|
626
|
+
...listArgs(a),
|
|
627
|
+
from: str(a.from),
|
|
628
|
+
to: str(a.to),
|
|
629
|
+
account: str(a.account),
|
|
630
|
+
connection: str(a.connection),
|
|
631
|
+
type: str(a.type)
|
|
632
|
+
})
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
name: "get_transaction",
|
|
636
|
+
description: "Read a single transaction by its `SK` \u2014 the composite key from list_transactions, NOT the short `id` field, which this route does not accept. Errors TRANSACTION_NOT_FOUND if not in scope.",
|
|
637
|
+
inputSchema: {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: {
|
|
640
|
+
id: {
|
|
641
|
+
type: "string",
|
|
642
|
+
description: 'the transaction\'s `SK` from list_transactions, e.g. "TRANSACTION#TYPE#CHECKING#PROVIDER#leumi#RESOURCE<uuid>#<ulid>". The short `id` field of the same transaction will NOT resolve.'
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
required: ["id"]
|
|
646
|
+
},
|
|
647
|
+
run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: "list_categories",
|
|
651
|
+
description: "The static transaction-category taxonomy (English + Hebrew). Use to interpret transaction category fields. No arguments.",
|
|
652
|
+
inputSchema: NO_ARGS,
|
|
653
|
+
run: async (_a, ctx) => ({ data: await fetchCategories(ctx.config, ctx.now) })
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "list_providers",
|
|
657
|
+
description: "Reference data: the list of banks/providers, to resolve providerId values. No arguments.",
|
|
658
|
+
inputSchema: NO_ARGS,
|
|
659
|
+
run: (_a, ctx) => fetchProviders(ctx.config, ctx.now)
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "list_bank_branches",
|
|
663
|
+
description: "Reference data: bank branches. No arguments (this route takes no paging).",
|
|
664
|
+
inputSchema: NO_ARGS,
|
|
665
|
+
run: (_a, ctx) => fetchBranches(ctx.config, ctx.now)
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: "get_status",
|
|
669
|
+
description: "Per-connection freshness rollup: for each connection its status, data-through date, consent expiry, and account count, plus staleThresholdDays. Call this first to decide whether data is fresh enough before answering questions about balances or transactions.",
|
|
670
|
+
inputSchema: NO_ARGS,
|
|
671
|
+
run: async (_a, ctx) => {
|
|
672
|
+
const { data, staleThresholdDays } = await fetchStatus(ctx.config, ctx.now);
|
|
673
|
+
return { data, staleThresholdDays };
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: "refresh_connections",
|
|
678
|
+
description: "Trigger an on-demand refresh of ALL connections (org-wide). This COSTS 20 credits \u2014 always confirm with the user before calling it. Data lands after a few minutes; poll get_status to watch progress. Returns the refresh outcome (accepted / already_running).",
|
|
679
|
+
inputSchema: NO_ARGS,
|
|
680
|
+
run: (_a, ctx) => fetchRefresh(ctx.config, ctx.now)
|
|
681
|
+
}
|
|
682
|
+
];
|
|
683
|
+
var BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
|
684
|
+
function errorEnvelope(error) {
|
|
685
|
+
const e = error instanceof CliError ? error : new CliError(EXIT.UNEXPECTED, "UNEXPECTED", String(error?.message ?? error));
|
|
686
|
+
return { error: { code: e.code, message: e.message, ...e.extra } };
|
|
687
|
+
}
|
|
688
|
+
async function callTool(name, args, io) {
|
|
689
|
+
try {
|
|
690
|
+
const tool = BY_NAME.get(name);
|
|
691
|
+
if (!tool) {
|
|
692
|
+
throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
|
|
693
|
+
}
|
|
694
|
+
let config = io.config;
|
|
695
|
+
if (!config) {
|
|
696
|
+
const result = await loadConfig(io.env);
|
|
697
|
+
if (!result.ok) throw notConfigured(result);
|
|
698
|
+
config = result.config;
|
|
699
|
+
}
|
|
700
|
+
return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
|
|
701
|
+
} catch (error) {
|
|
702
|
+
return errorEnvelope(error);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
export {
|
|
706
|
+
TOOLS,
|
|
707
|
+
VERSION,
|
|
708
|
+
bearerConfig,
|
|
709
|
+
callTool
|
|
710
|
+
};
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
fetchTransactions,
|
|
17
17
|
loadConfig,
|
|
18
18
|
notConfigured
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-VE5WZRGU.js";
|
|
20
20
|
|
|
21
21
|
// src/mcp/server.ts
|
|
22
22
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -90,7 +90,7 @@ var TOOLS = [
|
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
name: "list_transactions",
|
|
93
|
-
description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is.",
|
|
93
|
+
description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is. To then read one transaction, pass its `SK` (not its `id`) to get_transaction.",
|
|
94
94
|
inputSchema: {
|
|
95
95
|
type: "object",
|
|
96
96
|
properties: {
|
|
@@ -113,8 +113,17 @@ var TOOLS = [
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
name: "get_transaction",
|
|
116
|
-
description: "Read a single transaction by
|
|
117
|
-
inputSchema:
|
|
116
|
+
description: "Read a single transaction by its `SK` \u2014 the composite key from list_transactions, NOT the short `id` field, which this route does not accept. Errors TRANSACTION_NOT_FOUND if not in scope.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
id: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: 'the transaction\'s `SK` from list_transactions, e.g. "TRANSACTION#TYPE#CHECKING#PROVIDER#leumi#RESOURCE<uuid>#<ulid>". The short `id` field of the same transaction will NOT resolve.'
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
required: ["id"]
|
|
126
|
+
},
|
|
118
127
|
run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
|
|
119
128
|
},
|
|
120
129
|
{
|
|
@@ -162,9 +171,13 @@ async function callTool(name, args, io) {
|
|
|
162
171
|
if (!tool) {
|
|
163
172
|
throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
|
|
164
173
|
}
|
|
165
|
-
|
|
166
|
-
if (!
|
|
167
|
-
|
|
174
|
+
let config = io.config;
|
|
175
|
+
if (!config) {
|
|
176
|
+
const result = await loadConfig(io.env);
|
|
177
|
+
if (!result.ok) throw notConfigured(result);
|
|
178
|
+
config = result.config;
|
|
179
|
+
}
|
|
180
|
+
return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
|
|
168
181
|
} catch (error) {
|
|
169
182
|
return errorEnvelope(error);
|
|
170
183
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "financy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Your Open-Finance accounts, balances and transactions in the terminal
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"financy": "dist/bin.js"
|
|
8
8
|
},
|
|
9
|
+
"types": "./dist/lib.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/lib.d.ts",
|
|
13
|
+
"import": "./dist/lib.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
9
17
|
"license": "Apache-2.0",
|
|
10
18
|
"author": "Open Finance AI <hello@open-finance.ai>",
|
|
11
19
|
"homepage": "https://github.com/open-finance-ai/financy#readme",
|
|
@@ -39,6 +47,7 @@
|
|
|
39
47
|
"node": ">=20"
|
|
40
48
|
},
|
|
41
49
|
"scripts": {
|
|
50
|
+
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
42
51
|
"build": "tsup",
|
|
43
52
|
"test": "vitest run",
|
|
44
53
|
"test:watch": "vitest",
|