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.
package/README.md CHANGED
@@ -50,7 +50,25 @@ financy setup --no-input # read FINANCY_CLIENT_ID / _SECRET / _USER_ID from
50
50
 
51
51
  `setup` validates the credentials against the live API before saving, so an
52
52
  unregistered/free account is caught immediately (exit `3` for bad credentials,
53
- `4` for an ineligible plan).
53
+ `4` for an ineligible plan). Validation happens **before** anything is written —
54
+ a failed `setup` saves nothing and says so.
55
+
56
+ ### Windows
57
+
58
+ The secret prompt echoes one `*` per accepted character, so you can see the value
59
+ arriving. If you paste and no `*`s appear, your console did not paste: the legacy
60
+ Windows console does not accept **Ctrl+V** at a prompt — use **right-click** or
61
+ **Ctrl+Shift+V**. A silently dropped paste used to be submitted as a mangled
62
+ secret and come back as a `401`.
63
+
64
+ The config file lives at `%USERPROFILE%\.config\financy\config.json`. Windows does
65
+ not enforce Unix file modes, so it is not restricted to your user the way it is on
66
+ macOS/Linux — treat it as readable by anything running as you.
67
+
68
+ If you hand-edit that file, note that PowerShell 5.1's `>` and `Out-File` write
69
+ UTF-16 and `Set-Content` writes a UTF-8 BOM. The CLI now reads all three, but
70
+ `financy config` is the way to check: it reports the file as `ok`, `missing`,
71
+ `malformed`, or `unreadable` rather than just showing blank credentials.
54
72
 
55
73
  ## Commands
56
74
 
package/dist/bin.js CHANGED
@@ -18,10 +18,12 @@ import {
18
18
  getConnections,
19
19
  loadConfig,
20
20
  mintToken,
21
+ notConfigured,
21
22
  readCredentialsFile,
22
23
  resolveEndpoints,
24
+ sanitizeCredential,
23
25
  writeCredentialsFile
24
- } from "./chunk-XODXLY7E.js";
26
+ } from "./chunk-QYWDNIM2.js";
25
27
 
26
28
  // src/run.ts
27
29
  import { Command, CommanderError } from "commander";
@@ -110,12 +112,17 @@ async function setupCommand(ctx) {
110
112
  } catch (error) {
111
113
  if (error instanceof CliError && error.code === "NOT_AVAILABLE_ON_PLAN") {
112
114
  await writeCredentialsFile(endpoints.configDir, credentials);
115
+ } else {
116
+ ctx.err(
117
+ "\u2717 Nothing was saved. financy setup writes the config file only after the\n credentials validate against the API. The failure was:\n"
118
+ );
113
119
  }
114
120
  throw error;
115
121
  }
116
122
  await writeCredentialsFile(endpoints.configDir, credentials);
123
+ const perms = (ctx.platform ?? process.platform) === "win32" ? "" : " (permissions 600)";
117
124
  ctx.out(
118
- `\u2713 Saved to ${join(endpoints.configDir, "config.json")} (permissions 600)
125
+ `\u2713 Saved to ${join(endpoints.configDir, "config.json")}${perms}
119
126
 
120
127
  Try: financy status
121
128
  `
@@ -123,9 +130,9 @@ Try: financy status
123
130
  return EXIT.OK;
124
131
  }
125
132
  function credentialsFromEnv(env) {
126
- const clientId = env.FINANCY_CLIENT_ID;
127
- const clientSecret = env.FINANCY_CLIENT_SECRET;
128
- const userId = env.FINANCY_USER_ID;
133
+ const clientId = sanitizeCredential(env.FINANCY_CLIENT_ID ?? "");
134
+ const clientSecret = sanitizeCredential(env.FINANCY_CLIENT_SECRET ?? "");
135
+ const userId = sanitizeCredential(env.FINANCY_USER_ID ?? "");
129
136
  if (!clientId || !clientSecret || !userId) {
130
137
  throw new CliError(
131
138
  EXIT.USAGE,
@@ -149,11 +156,13 @@ function maskId(v) {
149
156
  }
150
157
  async function configCommand(ctx) {
151
158
  const endpoints = resolveEndpoints(ctx.env);
152
- const file = await readCredentialsFile(endpoints.configDir) ?? {};
159
+ const fileResult = await readCredentialsFile(endpoints.configDir);
160
+ const file = fileResult.status === "ok" ? fileResult.credentials : {};
161
+ const fileStatus = fileResult.status === "ok" ? "ok" : fileResult.status === "missing" ? "not created yet \u2014 run financy setup" : `${fileResult.status}: ${fileResult.detail}`;
153
162
  const resolve = (envKey, fileVal) => {
154
163
  const envVal = ctx.env[envKey];
155
- if (envVal) return { value: envVal, source: "env" };
156
- if (fileVal) return { value: fileVal, source: "file" };
164
+ if (envVal) return { value: sanitizeCredential(envVal), source: "env" };
165
+ if (fileVal) return { value: sanitizeCredential(fileVal), source: "file" };
157
166
  return { source: "unset" };
158
167
  };
159
168
  const clientId = resolve("FINANCY_CLIENT_ID", file.clientId);
@@ -164,6 +173,7 @@ async function configCommand(ctx) {
164
173
  JSON.stringify(
165
174
  {
166
175
  configDir: endpoints.configDir,
176
+ configFileStatus: fileStatus,
167
177
  authTokenUrl: endpoints.authTokenUrl,
168
178
  apiBaseUrl: endpoints.apiBaseUrl,
169
179
  chatBaseUrl: endpoints.chatBaseUrl,
@@ -171,6 +181,9 @@ async function configCommand(ctx) {
171
181
  clientIdSource: clientId.source,
172
182
  clientSecretSet: clientSecret.source !== "unset",
173
183
  clientSecretSource: clientSecret.source,
184
+ // Length only — enough to spot a truncated or half-pasted secret, which
185
+ // is otherwise indistinguishable from a wrong one behind a 401.
186
+ clientSecretLength: clientSecret.value?.length ?? 0,
174
187
  userId: userId.value ?? null,
175
188
  userIdSource: userId.source
176
189
  },
@@ -184,12 +197,13 @@ async function configCommand(ctx) {
184
197
  "financy configuration",
185
198
  "",
186
199
  ` config dir ${endpoints.configDir}`,
200
+ ` config file ${fileStatus}`,
187
201
  ` auth url ${endpoints.authTokenUrl}`,
188
202
  ` api url ${endpoints.apiBaseUrl}`,
189
203
  ` chat url ${endpoints.chatBaseUrl}`,
190
204
  "",
191
205
  ` clientId ${maskId(clientId.value)} (${clientId.source})`,
192
- ` clientSecret ${clientSecret.source !== "unset" ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (set)" : "(unset)"} (${clientSecret.source})`,
206
+ ` clientSecret ${clientSecret.value ? `\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (set, ${clientSecret.value.length} chars)` : "(unset)"} (${clientSecret.source})`,
193
207
  ` userId ${userId.value ?? "(unset)"} (${userId.source})`
194
208
  ];
195
209
  ctx.out(lines.join("\n") + "\n");
@@ -293,13 +307,7 @@ function renderGet(ctx, envelope) {
293
307
  // src/commands/read-commands.ts
294
308
  async function loadOrThrow(env) {
295
309
  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
- }
310
+ if (!result.ok) throw notConfigured(result);
303
311
  return result.config;
304
312
  }
305
313
  var ils = (n) => {
@@ -338,7 +346,10 @@ var TRANSACTION_COLUMNS = [
338
346
  { header: "DESCRIPTION", value: (t) => dash(t.merchantName ?? t.description?.description) },
339
347
  { header: "CATEGORY", value: (t) => `${dash(t.category?.main)}/${dash(t.category?.sub)}` },
340
348
  { header: "TYPE", value: (t) => dash(t.type) },
341
- { header: "ID", value: (t) => t.id }
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 }
342
353
  ];
343
354
  var PROVIDER_COLUMNS = [
344
355
  { header: "ID", value: (p) => dash(p.providerFriendlyId ?? p.id) },
@@ -380,9 +391,9 @@ function registerReadCommands(program, base, setExit) {
380
391
  const envelope = await fetchTransactions(config, base.now, opts);
381
392
  setExit(renderList(ctx, envelope, TRANSACTION_COLUMNS));
382
393
  });
383
- transactions.command("get <id>").option("--json", "machine-readable output").action(async (id) => {
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) => {
384
395
  const config = await loadOrThrow(base.env);
385
- setExit(renderGet(ctx, await fetchTransaction(config, base.now, id)));
396
+ setExit(renderGet(ctx, await fetchTransaction(config, base.now, sk)));
386
397
  });
387
398
  program.command("categories").description("The category taxonomy (English + Hebrew)").option("--json", "machine-readable output").action(async () => {
388
399
  const config = await loadOrThrow(base.env);
@@ -533,26 +544,99 @@ async function exists(path) {
533
544
 
534
545
  // src/prompt.ts
535
546
  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) => {
547
+ var CTRL_C = String.fromCharCode(3);
548
+ var CTRL_U = String.fromCharCode(21);
549
+ var ESC = String.fromCharCode(27);
550
+ var BACKSPACE = String.fromCharCode(8);
551
+ var DEL = String.fromCharCode(127);
552
+ var SIGINT_EXIT = 130;
553
+ var PASTE_HINT = "note: a keystroke that entered no text was ignored. If you meant to paste and\nnothing appeared, this console does not paste with Ctrl+V \u2014 use right-click or\nCtrl+Shift+V, or type the value.\n";
554
+ function readLine(prompt, input, output) {
555
+ if (prompt) output.write(prompt);
556
+ const rl = createInterface({ input, terminal: false });
557
+ return new Promise((resolve) => {
558
+ rl.question("", (answer) => {
550
559
  rl.close();
551
- if (secret) process.stdout.write("\n");
552
- resolve(answer.trim());
560
+ resolve(answer);
553
561
  });
554
562
  });
555
563
  }
564
+ function readMasked(prompt, input, output) {
565
+ output.write(prompt);
566
+ input.setRawMode?.(true);
567
+ input.setEncoding("utf8");
568
+ input.resume();
569
+ return new Promise((resolve) => {
570
+ let value = "";
571
+ let ignoredControl = false;
572
+ let inEscape = false;
573
+ const cleanup = () => {
574
+ input.removeListener("data", onData);
575
+ input.setRawMode?.(false);
576
+ input.pause();
577
+ };
578
+ const erase = (count) => {
579
+ if (count > 0) output.write(`${BACKSPACE} ${BACKSPACE}`.repeat(count));
580
+ };
581
+ const onData = (chunk) => {
582
+ for (const ch of String(chunk)) {
583
+ if (inEscape) {
584
+ if (/[A-Za-z~]/.test(ch)) inEscape = false;
585
+ continue;
586
+ }
587
+ if (ch === "\r" || ch === "\n") {
588
+ cleanup();
589
+ output.write("\n");
590
+ if (ignoredControl) output.write(PASTE_HINT);
591
+ resolve(value);
592
+ return;
593
+ }
594
+ if (ch === CTRL_C) {
595
+ cleanup();
596
+ output.write("\n");
597
+ process.exit(SIGINT_EXIT);
598
+ }
599
+ if (ch === DEL || ch === BACKSPACE) {
600
+ if (value.length > 0) {
601
+ value = value.slice(0, -1);
602
+ erase(1);
603
+ }
604
+ continue;
605
+ }
606
+ if (ch === CTRL_U) {
607
+ erase(value.length);
608
+ value = "";
609
+ continue;
610
+ }
611
+ if (ch === ESC) {
612
+ inEscape = true;
613
+ continue;
614
+ }
615
+ if (isControlChar(ch)) {
616
+ ignoredControl = true;
617
+ continue;
618
+ }
619
+ value += ch;
620
+ output.write("*");
621
+ }
622
+ };
623
+ input.on("data", onData);
624
+ });
625
+ }
626
+ function isControlChar(ch) {
627
+ const code = ch.codePointAt(0) ?? 0;
628
+ return code < 32 || code === 127;
629
+ }
630
+ function createDefaultPrompt(input = process.stdin, output = process.stdout) {
631
+ return async ({ label, secret }) => {
632
+ if (!secret) return sanitizeCredential(await readLine(`${label}: `, input, output));
633
+ if (!input.isTTY) {
634
+ const prompt = `${label} (not masked \u2014 this terminal cannot hide input): `;
635
+ return sanitizeCredential(await readLine(prompt, input, output));
636
+ }
637
+ return sanitizeCredential(await readMasked(`${label}: `, input, output));
638
+ };
639
+ }
556
640
 
557
641
  // src/run.ts
558
642
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -635,13 +719,7 @@ async function run(argv, io = {}) {
635
719
  let exitCode = EXIT.OK;
636
720
  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
721
  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
- }
722
+ if (!result.ok) throw notConfigured(result);
645
723
  exitCode = await statusCommand({ config: result.config, json, now, out, err });
646
724
  });
647
725
  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) => {
@@ -655,20 +733,14 @@ async function run(argv, io = {}) {
655
733
  });
656
734
  program.command("refresh").description("Trigger an on-demand refresh of all connections (20 credits)").option("--json", "machine-readable output").action(async () => {
657
735
  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
- }
736
+ if (!result.ok) throw notConfigured(result);
665
737
  exitCode = await refreshCommand({ config: result.config, json, now, out });
666
738
  });
667
739
  program.command("config").description("Show the resolved endpoints and credential sources (secret masked)").option("--json", "machine-readable output").action(async () => {
668
740
  exitCode = await configCommand({ env, json, out });
669
741
  });
670
742
  program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
671
- const { startMcpServer } = await import("./server-5G6DYQRD.js");
743
+ const { startMcpServer } = await import("./server-N5ZPO432.js");
672
744
  await startMcpServer(env);
673
745
  });
674
746
  const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
@@ -2,7 +2,6 @@
2
2
  import {
3
3
  CliError,
4
4
  EXIT,
5
- VERSION,
6
5
  fetchAccount,
7
6
  fetchAccounts,
8
7
  fetchBranches,
@@ -14,16 +13,9 @@ import {
14
13
  fetchStatus,
15
14
  fetchTransaction,
16
15
  fetchTransactions,
17
- loadConfig
18
- } from "./chunk-XODXLY7E.js";
19
-
20
- // src/mcp/server.ts
21
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
- import {
24
- ListToolsRequestSchema,
25
- CallToolRequestSchema
26
- } from "@modelcontextprotocol/sdk/types.js";
16
+ loadConfig,
17
+ notConfigured
18
+ } from "./chunk-QYWDNIM2.js";
27
19
 
28
20
  // src/mcp/tools.ts
29
21
  var str = (v) => v == null ? void 0 : String(v);
@@ -89,7 +81,7 @@ var TOOLS = [
89
81
  },
90
82
  {
91
83
  name: "list_transactions",
92
- 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.",
84
+ 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.",
93
85
  inputSchema: {
94
86
  type: "object",
95
87
  properties: {
@@ -112,8 +104,17 @@ var TOOLS = [
112
104
  },
113
105
  {
114
106
  name: "get_transaction",
115
- description: "Read a single transaction by id (from list_transactions). Errors TRANSACTION_NOT_FOUND if not in scope.",
116
- inputSchema: idSchema("transaction"),
107
+ 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.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ id: {
112
+ type: "string",
113
+ 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.'
114
+ }
115
+ },
116
+ required: ["id"]
117
+ },
117
118
  run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
118
119
  },
119
120
  {
@@ -161,39 +162,19 @@ async function callTool(name, args, io) {
161
162
  if (!tool) {
162
163
  throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
163
164
  }
164
- const result = await loadConfig(io.env);
165
- if (!result.ok) {
166
- throw new CliError(
167
- EXIT.AUTH,
168
- "NOT_CONFIGURED",
169
- `missing ${result.missing.join(", ")} \u2014 run financy setup`
170
- );
165
+ let config = io.config;
166
+ if (!config) {
167
+ const result = await loadConfig(io.env);
168
+ if (!result.ok) throw notConfigured(result);
169
+ config = result.config;
171
170
  }
172
- return await tool.run(args ?? {}, { config: result.config, now: io.now ?? /* @__PURE__ */ new Date() });
171
+ return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
173
172
  } catch (error) {
174
173
  return errorEnvelope(error);
175
174
  }
176
175
  }
177
176
 
178
- // src/mcp/server.ts
179
- async function startMcpServer(env) {
180
- const server = new Server(
181
- { name: "financy", version: VERSION },
182
- { capabilities: { tools: {} } }
183
- );
184
- server.setRequestHandler(ListToolsRequestSchema, () => ({
185
- tools: TOOLS.map((t) => ({
186
- name: t.name,
187
- description: t.description,
188
- inputSchema: t.inputSchema
189
- }))
190
- }));
191
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
192
- const result = await callTool(request.params.name, request.params.arguments ?? {}, { env });
193
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
194
- });
195
- await server.connect(new StdioServerTransport());
196
- }
197
177
  export {
198
- startMcpServer
178
+ TOOLS,
179
+ callTool
199
180
  };