financy 0.1.1 → 0.1.3

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
@@ -1,3 +1,17 @@
1
+ <!-- Absolute raw URLs, not relative paths: npm does not resolve relative image
2
+ paths when it renders this README on the package page. <picture> gives GitHub
3
+ a theme-aware logo; npm strips <source> but keeps the <img>, so the light-mode
4
+ logo is the fallback and reads correctly on npm's white page. -->
5
+ <picture>
6
+ <source
7
+ media="(prefers-color-scheme: dark)"
8
+ srcset="https://raw.githubusercontent.com/open-finance-ai/financy/main/assets/logo-dark.png">
9
+ <img
10
+ src="https://raw.githubusercontent.com/open-finance-ai/financy/main/assets/logo-light.png"
11
+ alt="Financy"
12
+ width="240">
13
+ </picture>
14
+
1
15
  # financy
2
16
 
3
17
  Your Open-Finance / Financy banking data — connections, accounts, balances,
@@ -36,7 +50,25 @@ financy setup --no-input # read FINANCY_CLIENT_ID / _SECRET / _USER_ID from
36
50
 
37
51
  `setup` validates the credentials against the live API before saving, so an
38
52
  unregistered/free account is caught immediately (exit `3` for bad credentials,
39
- `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.
40
72
 
41
73
  ## Commands
42
74
 
@@ -128,12 +160,6 @@ FINANCY_AUTH_URL=… FINANCY_API_URL=… FINANCY_CHAT_URL=… FINANCY_AUDIENCE=
128
160
  npm run smoke
129
161
  ```
130
162
 
131
- ## Spec & design
132
-
133
- - **Spec:** [docs/plan/PRD.md](docs/plan/PRD.md)
134
- - **Build issues:** [docs/plan/build-issues/](docs/plan/build-issues/)
135
- - **Interface reference** (locked runnable prototype): [docs/plan/prototype/](docs/plan/prototype/)
136
-
137
163
  ## License
138
164
 
139
165
  [Apache-2.0](LICENSE)
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-RDAO3F65.js";
26
+ } from "./chunk-YOW6MO6F.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) => {
@@ -533,26 +541,99 @@ async function exists(path) {
533
541
 
534
542
  // src/prompt.ts
535
543
  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) => {
544
+ var CTRL_C = String.fromCharCode(3);
545
+ var CTRL_U = String.fromCharCode(21);
546
+ var ESC = String.fromCharCode(27);
547
+ var BACKSPACE = String.fromCharCode(8);
548
+ var DEL = String.fromCharCode(127);
549
+ var SIGINT_EXIT = 130;
550
+ 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";
551
+ function readLine(prompt, input, output) {
552
+ if (prompt) output.write(prompt);
553
+ const rl = createInterface({ input, terminal: false });
554
+ return new Promise((resolve) => {
555
+ rl.question("", (answer) => {
550
556
  rl.close();
551
- if (secret) process.stdout.write("\n");
552
- resolve(answer.trim());
557
+ resolve(answer);
553
558
  });
554
559
  });
555
560
  }
561
+ function readMasked(prompt, input, output) {
562
+ output.write(prompt);
563
+ input.setRawMode?.(true);
564
+ input.setEncoding("utf8");
565
+ input.resume();
566
+ return new Promise((resolve) => {
567
+ let value = "";
568
+ let ignoredControl = false;
569
+ let inEscape = false;
570
+ const cleanup = () => {
571
+ input.removeListener("data", onData);
572
+ input.setRawMode?.(false);
573
+ input.pause();
574
+ };
575
+ const erase = (count) => {
576
+ if (count > 0) output.write(`${BACKSPACE} ${BACKSPACE}`.repeat(count));
577
+ };
578
+ const onData = (chunk) => {
579
+ for (const ch of String(chunk)) {
580
+ if (inEscape) {
581
+ if (/[A-Za-z~]/.test(ch)) inEscape = false;
582
+ continue;
583
+ }
584
+ if (ch === "\r" || ch === "\n") {
585
+ cleanup();
586
+ output.write("\n");
587
+ if (ignoredControl) output.write(PASTE_HINT);
588
+ resolve(value);
589
+ return;
590
+ }
591
+ if (ch === CTRL_C) {
592
+ cleanup();
593
+ output.write("\n");
594
+ process.exit(SIGINT_EXIT);
595
+ }
596
+ if (ch === DEL || ch === BACKSPACE) {
597
+ if (value.length > 0) {
598
+ value = value.slice(0, -1);
599
+ erase(1);
600
+ }
601
+ continue;
602
+ }
603
+ if (ch === CTRL_U) {
604
+ erase(value.length);
605
+ value = "";
606
+ continue;
607
+ }
608
+ if (ch === ESC) {
609
+ inEscape = true;
610
+ continue;
611
+ }
612
+ if (isControlChar(ch)) {
613
+ ignoredControl = true;
614
+ continue;
615
+ }
616
+ value += ch;
617
+ output.write("*");
618
+ }
619
+ };
620
+ input.on("data", onData);
621
+ });
622
+ }
623
+ function isControlChar(ch) {
624
+ const code = ch.codePointAt(0) ?? 0;
625
+ return code < 32 || code === 127;
626
+ }
627
+ function createDefaultPrompt(input = process.stdin, output = process.stdout) {
628
+ return async ({ label, secret }) => {
629
+ if (!secret) return sanitizeCredential(await readLine(`${label}: `, input, output));
630
+ if (!input.isTTY) {
631
+ const prompt = `${label} (not masked \u2014 this terminal cannot hide input): `;
632
+ return sanitizeCredential(await readLine(prompt, input, output));
633
+ }
634
+ return sanitizeCredential(await readMasked(`${label}: `, input, output));
635
+ };
636
+ }
556
637
 
557
638
  // src/run.ts
558
639
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -635,13 +716,7 @@ async function run(argv, io = {}) {
635
716
  let exitCode = EXIT.OK;
636
717
  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
718
  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
- }
719
+ if (!result.ok) throw notConfigured(result);
645
720
  exitCode = await statusCommand({ config: result.config, json, now, out, err });
646
721
  });
647
722
  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 +730,14 @@ async function run(argv, io = {}) {
655
730
  });
656
731
  program.command("refresh").description("Trigger an on-demand refresh of all connections (20 credits)").option("--json", "machine-readable output").action(async () => {
657
732
  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
- }
733
+ if (!result.ok) throw notConfigured(result);
665
734
  exitCode = await refreshCommand({ config: result.config, json, now, out });
666
735
  });
667
736
  program.command("config").description("Show the resolved endpoints and credential sources (secret masked)").option("--json", "machine-readable output").action(async () => {
668
737
  exitCode = await configCommand({ env, json, out });
669
738
  });
670
739
  program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
671
- const { startMcpServer } = await import("./server-PPWL4KRY.js");
740
+ const { startMcpServer } = await import("./server-FZPZL65M.js");
672
741
  await startMcpServer(env);
673
742
  });
674
743
  const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "financy",
6
- version: "0.1.1",
6
+ version: "0.1.3",
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: {
@@ -63,7 +63,7 @@ var package_default = {
63
63
  typescript: "^5.5.4",
64
64
  "typescript-eslint": "^8.65.0",
65
65
  undici: "^6.19.8",
66
- vitest: "^2.0.5"
66
+ vitest: "^4.1.10"
67
67
  }
68
68
  };
69
69
 
@@ -71,10 +71,64 @@ var package_default = {
71
71
  var VERSION = package_default.version;
72
72
  var USER_AGENT = `financy-cli/${VERSION}`;
73
73
 
74
+ // src/exit-codes.ts
75
+ var EXIT = {
76
+ OK: 0,
77
+ UNEXPECTED: 1,
78
+ USAGE: 2,
79
+ AUTH: 3,
80
+ PLAN: 4,
81
+ CREDITS: 5,
82
+ NOT_FOUND: 6,
83
+ API: 7
84
+ };
85
+
86
+ // src/errors.ts
87
+ var CliError = class extends Error {
88
+ code;
89
+ exitCode;
90
+ extra;
91
+ constructor(exitCode, code, message, extra = {}) {
92
+ super(message);
93
+ this.name = "CliError";
94
+ this.code = code;
95
+ this.exitCode = exitCode;
96
+ this.extra = extra;
97
+ }
98
+ };
99
+ var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
100
+ var planNotEligible = () => new CliError(
101
+ EXIT.PLAN,
102
+ "NOT_AVAILABLE_ON_PLAN",
103
+ "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
104
+ );
105
+ var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
106
+ var insufficientCredits = (cost, balance) => new CliError(
107
+ EXIT.CREDITS,
108
+ "INSUFFICIENT_CREDITS",
109
+ `an initiated refresh costs ${cost} credits; you have ${balance}`,
110
+ { cost, balance }
111
+ );
112
+ var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
113
+ var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
114
+
74
115
  // src/config.ts
75
116
  import { homedir } from "os";
76
117
  import { join } from "path";
77
118
  import { mkdir, readFile, writeFile } from "fs/promises";
119
+
120
+ // src/credentials.ts
121
+ function isControl(ch) {
122
+ const code = ch.codePointAt(0) ?? 0;
123
+ return code < 32 || code === 127;
124
+ }
125
+ function sanitizeCredential(raw) {
126
+ const stripped = Array.from(raw).filter((ch) => !isControl(ch)).join("").trim();
127
+ const quoted = /^(["'])(.*)\1$/.exec(stripped);
128
+ return (quoted?.[2] ?? stripped).trim();
129
+ }
130
+
131
+ // src/config.ts
78
132
  var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
79
133
  var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
80
134
  var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
@@ -94,14 +148,39 @@ function resolveEndpoints(env) {
94
148
  function configFile(configDir) {
95
149
  return join(configDir, "config.json");
96
150
  }
151
+ function decodeConfigBytes(bytes) {
152
+ if (bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
153
+ if (bytes[0] === 254 && bytes[1] === 255) {
154
+ return Buffer.from(bytes.subarray(2)).swap16().toString("utf16le");
155
+ }
156
+ if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
157
+ return bytes.subarray(3).toString("utf8");
158
+ }
159
+ return bytes.toString("utf8");
160
+ }
97
161
  async function readCredentialsFile(configDir) {
162
+ let bytes;
98
163
  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;
164
+ bytes = await readFile(configFile(configDir));
165
+ } catch (error) {
166
+ const code = error.code;
167
+ if (code === "ENOENT") return { status: "missing" };
168
+ return { status: "unreadable", detail: code ?? String(error) };
104
169
  }
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(decodeConfigBytes(bytes));
173
+ } catch (error) {
174
+ return { status: "malformed", detail: `not valid JSON (${error.message})` };
175
+ }
176
+ const credentials = parsed?.profiles?.default;
177
+ if (!credentials || typeof credentials !== "object") {
178
+ return {
179
+ status: "malformed",
180
+ detail: 'no "profiles.default" object \u2014 expected {"profiles":{"default":{\u2026}}}'
181
+ };
182
+ }
183
+ return { status: "ok", credentials };
105
184
  }
106
185
  async function writeCredentialsFile(configDir, credentials) {
107
186
  await mkdir(configDir, { recursive: true, mode: 448 });
@@ -111,63 +190,38 @@ async function writeCredentialsFile(configDir, credentials) {
111
190
  { mode: 384 }
112
191
  );
113
192
  }
193
+ function pick(envValue, fileValue) {
194
+ const raw = envValue ?? fileValue;
195
+ if (raw === void 0) return void 0;
196
+ const value = sanitizeCredential(raw);
197
+ return value === "" ? void 0 : value;
198
+ }
114
199
  async function loadConfig(env) {
115
200
  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;
201
+ const result = await readCredentialsFile(endpoints.configDir);
202
+ const file = result.status === "ok" ? result.credentials : {};
203
+ const fileProblem = result.status === "missing" || result.status === "ok" ? void 0 : `${configFile(endpoints.configDir)} could not be read: ${result.detail}`;
204
+ const clientId = pick(env.FINANCY_CLIENT_ID, file.clientId);
205
+ const clientSecret = pick(env.FINANCY_CLIENT_SECRET, file.clientSecret);
206
+ const userId = pick(env.FINANCY_USER_ID, file.userId);
120
207
  const missing = [];
121
208
  if (!clientId) missing.push("FINANCY_CLIENT_ID");
122
209
  if (!clientSecret) missing.push("FINANCY_CLIENT_SECRET");
123
210
  if (!userId) missing.push("FINANCY_USER_ID");
124
- if (!clientId || !clientSecret || !userId) return { ok: false, missing };
211
+ if (!clientId || !clientSecret || !userId) return { ok: false, missing, fileProblem };
125
212
  return {
126
213
  ok: true,
127
214
  config: { clientId, clientSecret, userId, ...endpoints }
128
215
  };
129
216
  }
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);
217
+ function notConfigured(result) {
218
+ const suffix = result.fileProblem ? ` \u2014 ${result.fileProblem}` : "";
219
+ return new CliError(
220
+ EXIT.AUTH,
221
+ "NOT_CONFIGURED",
222
+ `missing ${result.missing.join(", ")} \u2014 run financy setup${suffix}`
223
+ );
224
+ }
171
225
 
172
226
  // src/http.ts
173
227
  async function httpFetch(url, init) {
@@ -468,13 +522,15 @@ function fetchBranches(config, now) {
468
522
  }
469
523
 
470
524
  export {
525
+ sanitizeCredential,
526
+ EXIT,
527
+ CliError,
471
528
  resolveEndpoints,
472
529
  readCredentialsFile,
473
530
  writeCredentialsFile,
474
531
  loadConfig,
532
+ notConfigured,
475
533
  VERSION,
476
- EXIT,
477
- CliError,
478
534
  getConnections,
479
535
  mintToken,
480
536
  STALE_THRESHOLD_DAYS,
@@ -14,8 +14,9 @@ import {
14
14
  fetchStatus,
15
15
  fetchTransaction,
16
16
  fetchTransactions,
17
- loadConfig
18
- } from "./chunk-RDAO3F65.js";
17
+ loadConfig,
18
+ notConfigured
19
+ } from "./chunk-YOW6MO6F.js";
19
20
 
20
21
  // src/mcp/server.ts
21
22
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -162,13 +163,7 @@ async function callTool(name, args, io) {
162
163
  throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
163
164
  }
164
165
  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
- );
171
- }
166
+ if (!result.ok) throw notConfigured(result);
172
167
  return await tool.run(args ?? {}, { config: result.config, now: io.now ?? /* @__PURE__ */ new Date() });
173
168
  } catch (error) {
174
169
  return errorEnvelope(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "financy",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Your Open-Finance accounts, balances and transactions in the terminal — with an embedded MCP server and agent skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,6 +60,6 @@
60
60
  "typescript": "^5.5.4",
61
61
  "typescript-eslint": "^8.65.0",
62
62
  "undici": "^6.19.8",
63
- "vitest": "^2.0.5"
63
+ "vitest": "^4.1.10"
64
64
  }
65
65
  }
@@ -30,6 +30,9 @@ These are not optional and they apply to every step below.
30
30
  pastes it anyway, do not repeat it, do not use it in a command, and tell them
31
31
  to rotate it in Financy → Settings → API. Never print it, read it back for
32
32
  confirmation, or include it in a summary, and never `cat` the config file.
33
+ If `setup` warns that the input is **not masked** (a terminal where Node cannot
34
+ hide input, e.g. Git Bash / mintty, where stdin is a pipe), tell the user to run
35
+ it in PowerShell or Windows Terminal instead of typing the secret in the clear.
33
36
  - **Confirm before spending credits.** `financy refresh` costs 20 credits per
34
37
  run. Never run it without the user explicitly agreeing to that cost in this
35
38
  conversation.
@@ -95,7 +98,18 @@ where to find the three values: the Financy app → **Settings → API**.
95
98
 
96
99
  `setup` validates the credentials against the live API before saving, so a wrong
97
100
  value fails immediately rather than on their first real command. On success it
98
- writes `~/.config/financy/config.json` with permissions `600` and prints the path.
101
+ writes `~/.config/financy/config.json` and prints the path — permissions `600` on
102
+ macOS/Linux; on Windows the path is
103
+ `%USERPROFILE%\.config\financy\config.json` and file modes are not enforced.
104
+
105
+ A **failed** `setup` writes nothing at all. If the user believes they entered the
106
+ credentials but `financy config` shows them unset, setup did not get past
107
+ validation — do not go looking for a file-reading problem.
108
+
109
+ On Windows the secret prompt prints one `*` per character. If the user says they
110
+ pasted and saw no `*`s, their console did not paste: the legacy Windows console
111
+ ignores **Ctrl+V** at a prompt. Tell them to use **right-click** or
112
+ **Ctrl+Shift+V**, or to type the value.
99
113
 
100
114
  Ask the user to tell you the exit code or what it printed — that is all you need.
101
115
  Do not ask them to paste any of the three values.
@@ -103,8 +117,12 @@ Do not ask them to paste any of the three values.
103
117
  Exit codes from `setup`:
104
118
 
105
119
  - `0` — saved and verified.
106
- - `3` — the credentials were rejected. Ask them to run `financy setup` again and
107
- re-copy the values; a partial paste or a stray space is the usual cause.
120
+ - `3` — the credentials were rejected, and nothing was saved. Ask them to run
121
+ `financy setup` again and re-copy the values; a partial paste is the usual
122
+ cause (surrounding whitespace and wrapping quotes are stripped automatically).
123
+ Have them check that `Client ID` and `User ID` did not go into each other's
124
+ prompt — `financy config` shows the `userId` in the clear, so a swap is visible
125
+ there.
108
126
  - `4` — the credentials are valid but the plan is not eligible. The CLI still
109
127
  saves them so a later upgrade works immediately. Go to *Handling exit 4*.
110
128
  - `7` — the API could not be reached. This is a network or endpoint problem, not
@@ -160,8 +178,11 @@ the secret:
160
178
  financy config
161
179
  ```
162
180
 
163
- It prints the resolved endpoints and marks each credential as coming from the
164
- environment or the config file, with the secret masked.
181
+ It prints the resolved endpoints, the state of the config file (`ok`, `missing`,
182
+ `malformed`, `unreadable`), and marks each credential as coming from the
183
+ environment or the config file. The secret is never printed — only its length,
184
+ which is enough to spot a truncated paste. This is the first command to run when a
185
+ user reports a `401`.
165
186
 
166
187
  ## MCP alternative
167
188