financy 0.1.2 → 0.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.
@@ -3,12 +3,20 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "financy",
6
- version: "0.1.2",
6
+ version: "0.2.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,7 +50,9 @@ 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",
55
+ "build:lambda": "tsup --config tsup.lambda.config.ts",
46
56
  test: "vitest run",
47
57
  "test:watch": "vitest",
48
58
  typecheck: "tsc --noEmit",
@@ -52,7 +62,8 @@ var package_default = {
52
62
  },
53
63
  dependencies: {
54
64
  "@modelcontextprotocol/sdk": "^1.29.0",
55
- commander: "^12.1.0"
65
+ commander: "^12.1.0",
66
+ jose: "^6.2.9"
56
67
  },
57
68
  devDependencies: {
58
69
  "@eslint/js": "^10.0.1",
@@ -71,10 +82,64 @@ var package_default = {
71
82
  var VERSION = package_default.version;
72
83
  var USER_AGENT = `financy-cli/${VERSION}`;
73
84
 
85
+ // src/exit-codes.ts
86
+ var EXIT = {
87
+ OK: 0,
88
+ UNEXPECTED: 1,
89
+ USAGE: 2,
90
+ AUTH: 3,
91
+ PLAN: 4,
92
+ CREDITS: 5,
93
+ NOT_FOUND: 6,
94
+ API: 7
95
+ };
96
+
97
+ // src/errors.ts
98
+ var CliError = class extends Error {
99
+ code;
100
+ exitCode;
101
+ extra;
102
+ constructor(exitCode, code, message, extra = {}) {
103
+ super(message);
104
+ this.name = "CliError";
105
+ this.code = code;
106
+ this.exitCode = exitCode;
107
+ this.extra = extra;
108
+ }
109
+ };
110
+ var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
111
+ var planNotEligible = () => new CliError(
112
+ EXIT.PLAN,
113
+ "NOT_AVAILABLE_ON_PLAN",
114
+ "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
115
+ );
116
+ var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
117
+ var insufficientCredits = (cost, balance) => new CliError(
118
+ EXIT.CREDITS,
119
+ "INSUFFICIENT_CREDITS",
120
+ `an initiated refresh costs ${cost} credits; you have ${balance}`,
121
+ { cost, balance }
122
+ );
123
+ var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
124
+ var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
125
+
74
126
  // src/config.ts
75
127
  import { homedir } from "os";
76
128
  import { join } from "path";
77
129
  import { mkdir, readFile, writeFile } from "fs/promises";
130
+
131
+ // src/credentials.ts
132
+ function isControl(ch) {
133
+ const code = ch.codePointAt(0) ?? 0;
134
+ return code < 32 || code === 127;
135
+ }
136
+ function sanitizeCredential(raw) {
137
+ const stripped = Array.from(raw).filter((ch) => !isControl(ch)).join("").trim();
138
+ const quoted = /^(["'])(.*)\1$/.exec(stripped);
139
+ return (quoted?.[2] ?? stripped).trim();
140
+ }
141
+
142
+ // src/config.ts
78
143
  var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
79
144
  var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
80
145
  var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
@@ -94,14 +159,39 @@ function resolveEndpoints(env) {
94
159
  function configFile(configDir) {
95
160
  return join(configDir, "config.json");
96
161
  }
162
+ function decodeConfigBytes(bytes) {
163
+ if (bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
164
+ if (bytes[0] === 254 && bytes[1] === 255) {
165
+ return Buffer.from(bytes.subarray(2)).swap16().toString("utf16le");
166
+ }
167
+ if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
168
+ return bytes.subarray(3).toString("utf8");
169
+ }
170
+ return bytes.toString("utf8");
171
+ }
97
172
  async function readCredentialsFile(configDir) {
173
+ let bytes;
98
174
  try {
99
- const raw = await readFile(configFile(configDir), "utf8");
100
- const parsed = JSON.parse(raw);
101
- return parsed.profiles?.default ?? null;
102
- } catch {
103
- return null;
175
+ bytes = await readFile(configFile(configDir));
176
+ } catch (error) {
177
+ const code = error.code;
178
+ if (code === "ENOENT") return { status: "missing" };
179
+ return { status: "unreadable", detail: code ?? String(error) };
180
+ }
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(decodeConfigBytes(bytes));
184
+ } catch (error) {
185
+ return { status: "malformed", detail: `not valid JSON (${error.message})` };
186
+ }
187
+ const credentials = parsed?.profiles?.default;
188
+ if (!credentials || typeof credentials !== "object") {
189
+ return {
190
+ status: "malformed",
191
+ detail: 'no "profiles.default" object \u2014 expected {"profiles":{"default":{\u2026}}}'
192
+ };
104
193
  }
194
+ return { status: "ok", credentials };
105
195
  }
106
196
  async function writeCredentialsFile(configDir, credentials) {
107
197
  await mkdir(configDir, { recursive: true, mode: 448 });
@@ -111,63 +201,48 @@ async function writeCredentialsFile(configDir, credentials) {
111
201
  { mode: 384 }
112
202
  );
113
203
  }
204
+ function bearerConfig(env, bearerToken, abortSignal) {
205
+ return {
206
+ ...resolveEndpoints(env),
207
+ clientId: "",
208
+ clientSecret: "",
209
+ userId: "",
210
+ bearerToken,
211
+ abortSignal
212
+ };
213
+ }
214
+ function pick(envValue, fileValue) {
215
+ const raw = envValue ?? fileValue;
216
+ if (raw === void 0) return void 0;
217
+ const value = sanitizeCredential(raw);
218
+ return value === "" ? void 0 : value;
219
+ }
114
220
  async function loadConfig(env) {
115
221
  const endpoints = resolveEndpoints(env);
116
- const file = await readCredentialsFile(endpoints.configDir) ?? {};
117
- const clientId = env.FINANCY_CLIENT_ID ?? file.clientId;
118
- const clientSecret = env.FINANCY_CLIENT_SECRET ?? file.clientSecret;
119
- const userId = env.FINANCY_USER_ID ?? file.userId;
222
+ const result = await readCredentialsFile(endpoints.configDir);
223
+ const file = result.status === "ok" ? result.credentials : {};
224
+ const fileProblem = result.status === "missing" || result.status === "ok" ? void 0 : `${configFile(endpoints.configDir)} could not be read: ${result.detail}`;
225
+ const clientId = pick(env.FINANCY_CLIENT_ID, file.clientId);
226
+ const clientSecret = pick(env.FINANCY_CLIENT_SECRET, file.clientSecret);
227
+ const userId = pick(env.FINANCY_USER_ID, file.userId);
120
228
  const missing = [];
121
229
  if (!clientId) missing.push("FINANCY_CLIENT_ID");
122
230
  if (!clientSecret) missing.push("FINANCY_CLIENT_SECRET");
123
231
  if (!userId) missing.push("FINANCY_USER_ID");
124
- if (!clientId || !clientSecret || !userId) return { ok: false, missing };
232
+ if (!clientId || !clientSecret || !userId) return { ok: false, missing, fileProblem };
125
233
  return {
126
234
  ok: true,
127
235
  config: { clientId, clientSecret, userId, ...endpoints }
128
236
  };
129
237
  }
130
-
131
- // src/exit-codes.ts
132
- var EXIT = {
133
- OK: 0,
134
- UNEXPECTED: 1,
135
- USAGE: 2,
136
- AUTH: 3,
137
- PLAN: 4,
138
- CREDITS: 5,
139
- NOT_FOUND: 6,
140
- API: 7
141
- };
142
-
143
- // src/errors.ts
144
- var CliError = class extends Error {
145
- code;
146
- exitCode;
147
- extra;
148
- constructor(exitCode, code, message, extra = {}) {
149
- super(message);
150
- this.name = "CliError";
151
- this.code = code;
152
- this.exitCode = exitCode;
153
- this.extra = extra;
154
- }
155
- };
156
- var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
157
- var planNotEligible = () => new CliError(
158
- EXIT.PLAN,
159
- "NOT_AVAILABLE_ON_PLAN",
160
- "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
161
- );
162
- var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
163
- var insufficientCredits = (cost, balance) => new CliError(
164
- EXIT.CREDITS,
165
- "INSUFFICIENT_CREDITS",
166
- `an initiated refresh costs ${cost} credits; you have ${balance}`,
167
- { cost, balance }
168
- );
169
- var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
170
- var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
238
+ function notConfigured(result) {
239
+ const suffix = result.fileProblem ? ` \u2014 ${result.fileProblem}` : "";
240
+ return new CliError(
241
+ EXIT.AUTH,
242
+ "NOT_CONFIGURED",
243
+ `missing ${result.missing.join(", ")} \u2014 run financy setup${suffix}`
244
+ );
245
+ }
171
246
 
172
247
  // src/http.ts
173
248
  async function httpFetch(url, init) {
@@ -258,6 +333,7 @@ async function getAccessToken(config, now) {
258
333
  return mintAndCache(config);
259
334
  }
260
335
  async function authorized(config, now, request) {
336
+ if (config.bearerToken) return request(config.bearerToken);
261
337
  const token = await getAccessToken(config, now);
262
338
  try {
263
339
  return await request(token);
@@ -297,7 +373,10 @@ async function getPage(config, token, path, query = {}) {
297
373
  for (const [key, value] of Object.entries(query)) {
298
374
  if (value !== void 0) url.searchParams.set(key, value);
299
375
  }
300
- const res = await httpFetch(url.toString(), { headers: authHeaders(token) });
376
+ const res = await httpFetch(url.toString(), {
377
+ headers: authHeaders(token),
378
+ signal: config.abortSignal
379
+ });
301
380
  await ensureOk(res);
302
381
  const raw = await res.json().catch(() => ({}));
303
382
  debugBody(path, raw);
@@ -310,7 +389,10 @@ function normalizePage(raw) {
310
389
  return { items, nextPage: obj.nextPage ?? null, count: obj.count ?? items.length };
311
390
  }
312
391
  async function getById(config, token, path) {
313
- const res = await httpFetch(config.apiBaseUrl + path, { headers: authHeaders(token) });
392
+ const res = await httpFetch(config.apiBaseUrl + path, {
393
+ headers: authHeaders(token),
394
+ signal: config.abortSignal
395
+ });
314
396
  if (res.status === 404) return null;
315
397
  await ensureOk(res);
316
398
  const body = await res.json();
@@ -321,7 +403,8 @@ async function getById(config, token, path) {
321
403
  async function postRefresh(config, token) {
322
404
  const res = await httpFetch(config.chatBaseUrl + "/connections/refresh", {
323
405
  method: "POST",
324
- headers: authHeaders(token)
406
+ headers: authHeaders(token),
407
+ signal: config.abortSignal
325
408
  });
326
409
  if (res.status === 401) throw authFailed();
327
410
  if (res.status === 402) {
@@ -445,12 +528,20 @@ function fetchTransactions(config, now, args) {
445
528
  !!args.all
446
529
  );
447
530
  }
448
- function fetchTransaction(config, now, id) {
531
+ var BARE_TRANSACTION_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
532
+ function fetchTransaction(config, now, key) {
533
+ if (BARE_TRANSACTION_ID.test(key)) {
534
+ throw new CliError(
535
+ EXIT.USAGE,
536
+ "INVALID_ARGUMENT",
537
+ `'${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}")`
538
+ );
539
+ }
449
540
  return getOne(
450
541
  config,
451
542
  now,
452
- `/data/transactions/${encodeURIComponent(id)}`,
453
- notFound("TRANSACTION_NOT_FOUND", `no transaction ${id} in your scope`)
543
+ `/data/transactions/${encodeURIComponent(key)}`,
544
+ notFound("TRANSACTION_NOT_FOUND", `no transaction ${key} in your scope`)
454
545
  );
455
546
  }
456
547
  function fetchCategories(config, now) {
@@ -468,13 +559,16 @@ function fetchBranches(config, now) {
468
559
  }
469
560
 
470
561
  export {
562
+ sanitizeCredential,
563
+ EXIT,
564
+ CliError,
471
565
  resolveEndpoints,
472
566
  readCredentialsFile,
473
567
  writeCredentialsFile,
568
+ bearerConfig,
474
569
  loadConfig,
570
+ notConfigured,
475
571
  VERSION,
476
- EXIT,
477
- CliError,
478
572
  getConnections,
479
573
  mintToken,
480
574
  STALE_THRESHOLD_DAYS,
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 };