driggsby 0.8.0 → 0.9.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
@@ -131,6 +131,26 @@ saved or refused on its own, and the JSON result says which and why.
131
131
  `items delete` needs `--yes`. Read items back with
132
132
  `npx driggsby@latest query query_item_sql --sql "..."`.
133
133
 
134
+ ## Statement imports
135
+
136
+ Every import names its account, so Driggsby never picks one for you:
137
+
138
+ ```sh
139
+ npx driggsby@latest accounts list
140
+ npx driggsby@latest import statement.csv --account acct_7kq2
141
+ npx driggsby@latest import statement.csv --create-account --account-type card --account-name "Everyday Card"
142
+ ```
143
+
144
+ `accounts list` shows each account's ID. `--create-account` makes a new
145
+ account (`card` or `bank`) with that name; a name an account already has is
146
+ refused with that account's ID, so a script names it by ID from then on.
147
+ An import returns at once with an ID; `npx driggsby@latest import <ID> --wait`
148
+ follows it until the transactions are in. Every output ends with the exact
149
+ command to run next, including one command per answer when Driggsby needs a
150
+ fact it can't read from the file. Importing the same file again is safe: it
151
+ shows where that import stands. `import undo <ID>` shows what undoing would
152
+ remove (`--yes` does it), and `import list` lists your recent imports.
153
+
134
154
  ## License
135
155
 
136
156
  Apache-2.0. The bundled Inter font (`assets/Inter.woff2`), which
@@ -0,0 +1,211 @@
1
+ // Argv parsing for `driggsby import`: `import <FILE>` with the account it
2
+ // goes in (--account, or --create-account with its type and name), `import
3
+ // <ID>` with --wait and one answer flag, `import undo <FILE or ID> [--yes]`,
4
+ // `import list`, and `accounts list`.
5
+ // Same contract as the rest of the tree: help on -h/--help, usage errors
6
+ // exit 2 and name the fix, echoed argv is sanitized, and "--" ends options
7
+ // (so a file whose name starts with "-" still imports).
8
+ import { helpCommand, unexpectedArgument } from "./args-shared.js";
9
+ import { CliError } from "./cli-error.js";
10
+ import { ACCOUNTS_HELP, IMPORT_HELP } from "./help.js";
11
+ import {} from "./import/import-command.js";
12
+ import { sanitizeForTerminal } from "./terminal-text.js";
13
+ export const IMPORT_USAGE = "Usage: npx driggsby@latest import <FILE or ID> [--wait] [--account <ID> | --create-account --account-type <card|bank> --account-name <NAME>]";
14
+ const UNDO_USAGE = "Usage: npx driggsby@latest import undo <FILE or ID> [--yes]";
15
+ const LIST_USAGE = "Usage: npx driggsby@latest import list";
16
+ const ACCOUNTS_USAGE = "Usage: npx driggsby@latest accounts list";
17
+ const ANSWER_VALUES = {
18
+ "--dates": ["day-first", "month-first"],
19
+ "--amounts": ["positive-is-out", "positive-is-in"],
20
+ };
21
+ const ACCOUNT_VALUE = /^acct_[A-Za-z0-9_-]{1,64}$/;
22
+ const ACCOUNT_TYPES = ["card", "bank"];
23
+ const NAME_LIMIT = 100;
24
+ export function parseAccounts(argv) {
25
+ const [first, ...rest] = argv;
26
+ if (first === undefined || first === "-h" || first === "--help") {
27
+ return helpCommand(ACCOUNTS_HELP);
28
+ }
29
+ if (first !== "list") {
30
+ throw unexpectedArgument(first, ACCOUNTS_USAGE);
31
+ }
32
+ for (const token of rest) {
33
+ if (token === "-h" || token === "--help") {
34
+ return helpCommand(ACCOUNTS_HELP);
35
+ }
36
+ throw unexpectedArgument(token, ACCOUNTS_USAGE);
37
+ }
38
+ return { kind: "import", options: { action: "accounts" } };
39
+ }
40
+ export function parseImport(argv) {
41
+ const first = argv[0];
42
+ if (first === "list") {
43
+ return command(parseList(argv.slice(1)));
44
+ }
45
+ if (first === "undo") {
46
+ return command(parseUndo(argv.slice(1)));
47
+ }
48
+ return command(parseRun(argv));
49
+ }
50
+ function command(options) {
51
+ return "kind" in options ? options : { kind: "import", options };
52
+ }
53
+ function parseList(argv) {
54
+ for (const token of argv) {
55
+ if (token === "-h" || token === "--help") {
56
+ return helpCommand(IMPORT_HELP);
57
+ }
58
+ throw unexpectedArgument(token, LIST_USAGE);
59
+ }
60
+ return { action: "list" };
61
+ }
62
+ function parseUndo(argv) {
63
+ let target = null;
64
+ let yes = false;
65
+ let optionsEnded = false;
66
+ for (const token of argv) {
67
+ if (!optionsEnded && token === "--") {
68
+ optionsEnded = true;
69
+ }
70
+ else if (!optionsEnded && (token === "-h" || token === "--help")) {
71
+ return helpCommand(IMPORT_HELP);
72
+ }
73
+ else if (!optionsEnded && token === "--yes") {
74
+ yes = true;
75
+ }
76
+ else if (target === null && (optionsEnded || !token.startsWith("-"))) {
77
+ target = token;
78
+ }
79
+ else {
80
+ throw unexpectedArgument(token, UNDO_USAGE);
81
+ }
82
+ }
83
+ if (target === null) {
84
+ throw missing("<FILE or ID>", UNDO_USAGE);
85
+ }
86
+ return { action: "undo", target, yes };
87
+ }
88
+ function parseRun(argv) {
89
+ const found = { target: null, wait: false, account: null, create: false, type: null, name: null, dates: null, amounts: null };
90
+ let optionsEnded = false;
91
+ for (let index = 0; index < argv.length; index += 1) {
92
+ const token = argv[index] ?? "";
93
+ if (!optionsEnded && token === "--") {
94
+ optionsEnded = true;
95
+ continue;
96
+ }
97
+ if (!optionsEnded && (token === "-h" || token === "--help")) {
98
+ return helpCommand(IMPORT_HELP);
99
+ }
100
+ if (!optionsEnded && token === "--wait") {
101
+ found.wait = true;
102
+ continue;
103
+ }
104
+ if (!optionsEnded && token === "--create-account") {
105
+ found.create = true;
106
+ continue;
107
+ }
108
+ const flag = optionsEnded ? null : valueFlag(token);
109
+ if (flag !== null) {
110
+ const inline = token.includes("=") ? token.slice(token.indexOf("=") + 1) : null;
111
+ const value = inline ?? argv[index + 1];
112
+ if (inline === null) {
113
+ index += 1;
114
+ }
115
+ setValue(found, flag, value);
116
+ continue;
117
+ }
118
+ if (found.target === null && (optionsEnded || !token.startsWith("-"))) {
119
+ found.target = token;
120
+ continue;
121
+ }
122
+ throw unexpectedArgument(token, IMPORT_USAGE);
123
+ }
124
+ if (found.target === null) {
125
+ throw missing("<FILE or ID>", IMPORT_USAGE);
126
+ }
127
+ return { action: "run", target: found.target, wait: found.wait, account: found.account, create: checkedCreate(found), dates: found.dates, amounts: found.amounts };
128
+ }
129
+ // The new account, whole, or a usage error naming what's missing; one way to
130
+ // name the account, and an account flag never alongside an answer.
131
+ function checkedCreate(found) {
132
+ if (found.account !== null && found.create) {
133
+ throw usage("name an existing account (--account) or a new one (--create-account), not both");
134
+ }
135
+ if (!found.create && (found.type !== null || found.name !== null)) {
136
+ throw usage("--account-type and --account-name go with --create-account");
137
+ }
138
+ if ((found.account !== null || found.create) && (found.dates !== null || found.amounts !== null)) {
139
+ throw usage("--dates and --amounts answer an import's question; --account and --create-account start an import. Give one or the other");
140
+ }
141
+ if (!found.create) {
142
+ return null;
143
+ }
144
+ if (found.type === null || found.name === null) {
145
+ throw usage("--create-account needs --account-type <card|bank> and --account-name <NAME>");
146
+ }
147
+ return { type: found.type, name: found.name };
148
+ }
149
+ function valueFlag(token) {
150
+ const name = token.split("=", 1)[0];
151
+ return name === "--account" || name === "--account-type" || name === "--account-name" || name === "--dates" || name === "--amounts" ? name : null;
152
+ }
153
+ function setValue(found, flag, value) {
154
+ if (value === undefined || value.trim() === "" || value.trim().startsWith("--")) {
155
+ throw new CliError(`error: a value is required for '${flag}' but none was supplied\n\n${IMPORT_USAGE}\n\nFor more information, try '--help'.`, 2);
156
+ }
157
+ if (flag === "--account") {
158
+ if (!ACCOUNT_VALUE.test(value)) {
159
+ throw new CliError(`error: invalid value '${sanitizeForTerminal(value)}' for '--account': name the account by its ID, like acct_7kq2\n` +
160
+ `(npx driggsby@latest accounts list shows each one)\n\n${IMPORT_USAGE}\n\nFor more information, try '--help'.`, 2);
161
+ }
162
+ found.account = once(found.account, flag, value);
163
+ return;
164
+ }
165
+ if (flag === "--account-type") {
166
+ if (!ACCOUNT_TYPES.includes(value)) {
167
+ throw invalid(flag, value, ACCOUNT_TYPES);
168
+ }
169
+ found.type = once(found.type, flag, value);
170
+ return;
171
+ }
172
+ if (flag === "--account-name") {
173
+ const name = value.trim();
174
+ // Characters a terminal or Driggsby would drop are refused here, so the
175
+ // name a retry prints is the name the account gets.
176
+ if (name.length > NAME_LIMIT || sanitizeForTerminal(name) !== name || /\p{Cf}/u.test(name)) {
177
+ throw new CliError(`error: invalid value for '--account-name': give the account's name, up to ${String(NAME_LIMIT)} characters\n\n${IMPORT_USAGE}\n\n` +
178
+ "For more information, try '--help'.", 2);
179
+ }
180
+ found.name = once(found.name, flag, name);
181
+ return;
182
+ }
183
+ const allowed = ANSWER_VALUES[flag];
184
+ if (!allowed.includes(value)) {
185
+ throw invalid(flag, value, allowed);
186
+ }
187
+ if (found.dates !== null || found.amounts !== null) {
188
+ throw usage("give one answer at a time: --dates or --amounts");
189
+ }
190
+ if (flag === "--dates") {
191
+ found.dates = value;
192
+ }
193
+ else {
194
+ found.amounts = value;
195
+ }
196
+ }
197
+ function once(current, flag, value) {
198
+ if (current !== null) {
199
+ throw usage(`'${flag}' was given more than once`);
200
+ }
201
+ return value;
202
+ }
203
+ function invalid(flag, value, allowed) {
204
+ return new CliError(`error: invalid value '${sanitizeForTerminal(value)}' for '${flag}'\n [possible values: ${allowed.join(", ")}]\n\n${IMPORT_USAGE}\n\nFor more information, try '--help'.`, 2);
205
+ }
206
+ function usage(problem) {
207
+ return new CliError(`error: ${problem}\n\n${IMPORT_USAGE}\n\nFor more information, try '--help'.`, 2);
208
+ }
209
+ function missing(argument, usage) {
210
+ return new CliError(`error: the following required arguments were not provided:\n ${argument}\n\n${usage}\n\nFor more information, try '--help'.`, 2);
211
+ }
@@ -8,6 +8,8 @@ import { didYouMean } from "./clap-suggestions.js";
8
8
  import { CliError } from "./cli-error.js";
9
9
  import { APP_TOOL_ALLOWLIST } from "./dev/tool-allowlist.js";
10
10
  import { QUERY_HELP } from "./help.js";
11
+ import { ITEM_ACTION_TOOLS } from "./items/item-actions.js";
12
+ import { RULE_ACTION_TOOLS } from "./rules/rule-actions.js";
11
13
  import { quotedForTerminal, wrapNames } from "./terminal-text.js";
12
14
  // The tools that take SQL: --sql applies only to these, and a retry
13
15
  // line names --sql only for them.
@@ -106,9 +108,25 @@ function invalidParams() {
106
108
  function missingTool() {
107
109
  return new CliError(`error: the following required arguments were not provided:\n <TOOL>\n\n${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
108
110
  }
111
+ // A tool with a command of its own (the items and rules actions): its
112
+ // refusal names that command rather than a read tool that looks alike.
113
+ function commandFor(tool) {
114
+ const commands = [["items", ITEM_ACTION_TOOLS], ["rules", RULE_ACTION_TOOLS]];
115
+ for (const [command, tools] of commands) {
116
+ const action = Object.keys(tools).find((name) => tools[name] === tool);
117
+ if (action !== undefined)
118
+ return `npx driggsby@latest ${command} ${action}`;
119
+ }
120
+ return undefined;
121
+ }
109
122
  function unknownTool(tool) {
110
- const similar = didYouMean(tool, [...APP_TOOL_ALLOWLIST]);
111
- const tip = similar === undefined ? "" : ` tip: a similar tool exists: '${similar}'\n\n`;
123
+ const command = commandFor(tool);
124
+ const similar = command === undefined ? didYouMean(tool, [...APP_TOOL_ALLOWLIST]) : undefined;
125
+ const tip = command !== undefined
126
+ ? ` tip: run ${tool} with its own command:\n ${command}\n\n`
127
+ : similar === undefined
128
+ ? ""
129
+ : ` tip: a similar tool exists: '${similar}'\n\n`;
112
130
  return new CliError(`error: ${quotedForTerminal(tool, 60)} isn't a tool a Driggsby app can call.\n` +
113
131
  `Apps can watch these read-only tools:\n` +
114
132
  `${wrapNames([...APP_TOOL_ALLOWLIST], " ")}\n\n${tip}${QUERY_USAGE}\n\nFor more information, try '--help'.`, 2);
@@ -4,6 +4,7 @@
4
4
  // terminal through an echoed argument, tip, or usage line.
5
5
  import { CliError } from "./cli-error.js";
6
6
  import {} from "./mcp-setup/known-client.js";
7
+ import {} from "./import/import-command.js";
7
8
  import {} from "./items/item-actions.js";
8
9
  import {} from "./rules/rule-actions.js";
9
10
  import { sanitizeForTerminal } from "./terminal-text.js";
package/dist/args.js CHANGED
@@ -13,6 +13,7 @@ import { parseInit } from "./args-init.js";
13
13
  import { parseLogin } from "./args-login.js";
14
14
  import { parseMcpSetup } from "./args-mcp-setup.js";
15
15
  import { parseQuery } from "./args-query.js";
16
+ import { parseAccounts, parseImport } from "./args-import.js";
16
17
  import { parseItems } from "./args-items.js";
17
18
  import { parseRules } from "./args-rules.js";
18
19
  import { removeDashesTip, similarArgumentTip, unexpectedArgument, unexpectedFlagValue, } from "./args-shared.js";
@@ -26,7 +27,7 @@ const MCP_USAGE = "Usage: npx driggsby@latest mcp <COMMAND>";
26
27
  const ROOT_LEVEL = {
27
28
  helpText: ROOT_HELP,
28
29
  usage: ROOT_USAGE,
29
- subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules", "items"],
30
+ subcommands: ["mcp", "login", "logout", "init", "dev", "deploy", "rollback", "versions", "delete", "query", "rules", "items", "import", "accounts"],
30
31
  longFlags: ["help", "version"],
31
32
  subcommandFlags: [
32
33
  { name: "mcp", longFlags: ["help"] },
@@ -41,6 +42,8 @@ const ROOT_LEVEL = {
41
42
  { name: "query", longFlags: ["sql", "params", "help"] },
42
43
  { name: "rules", longFlags: ["params", "params-file", "yes", "help"] },
43
44
  { name: "items", longFlags: ["params", "params-file", "yes", "help"] },
45
+ { name: "import", longFlags: ["wait", "account", "create-account", "account-type", "account-name", "dates", "amounts", "yes", "help"] },
46
+ { name: "accounts", longFlags: ["help"] },
44
47
  ],
45
48
  usagePrefix: "npx driggsby@latest",
46
49
  hasVersion: true,
@@ -114,6 +117,10 @@ function dispatchSubcommand(name, rest) {
114
117
  return parseRules(rest);
115
118
  case "items":
116
119
  return parseItems(rest);
120
+ case "import":
121
+ return parseImport(rest);
122
+ case "accounts":
123
+ return parseAccounts(rest);
117
124
  case "setup":
118
125
  return parseMcpSetup(rest);
119
126
  default:
@@ -190,7 +197,17 @@ function unknownLongFlagAtLevel(level, token, remainingArgs) {
190
197
  return unexpectedArgument(shown, level.usage);
191
198
  }
192
199
  // Flags anywhere in the tree whose next token is their value, not a word.
193
- const VALUE_FLAGS = new Set(["--params", "--params-file", "--sql", "--to", "-s", "--scope"]);
200
+ const VALUE_FLAGS = new Set([
201
+ "--params",
202
+ "--params-file",
203
+ "--sql",
204
+ "--to",
205
+ "-s",
206
+ "--scope",
207
+ "--account",
208
+ "--dates",
209
+ "--amounts",
210
+ ]);
194
211
  function firstTypedSubcommand(level, args) {
195
212
  return args.find((argument, index) => level.subcommands.includes(argument) && !VALUE_FLAGS.has(args[index - 1] ?? ""));
196
213
  }
package/dist/help.js CHANGED
@@ -23,6 +23,8 @@ Commands:
23
23
  query Run one Driggsby data tool and print its result as JSON.
24
24
  rules Create, change, and delete your transaction rules.
25
25
  items Save and remove the items behind your charges.
26
+ import Import a bank or card statement (a CSV file) into your accounts.
27
+ accounts List your accounts and the IDs an import names.
26
28
 
27
29
  Options:
28
30
  -h, --help Print help
@@ -40,6 +42,8 @@ Examples:
40
42
  npx driggsby@latest query get_overview
41
43
  npx driggsby@latest rules describe
42
44
  npx driggsby@latest items describe
45
+ npx driggsby@latest accounts list
46
+ npx driggsby@latest import statement.csv --account acct_7kq2
43
47
  `;
44
48
  export const INIT_HELP = `Create a new Driggsby app in a new folder.
45
49
 
@@ -269,6 +273,68 @@ Examples:
269
273
  npx driggsby@latest items save --params-file order.json
270
274
  npx driggsby@latest items delete --params-file refs.json --yes
271
275
  `;
276
+ export const IMPORT_HELP = `Import a bank or card statement (a CSV file) into your Driggsby accounts.
277
+
278
+ Sign in first with npx driggsby@latest login.
279
+
280
+ Usage:
281
+ npx driggsby@latest import <FILE> --account <ID> [--wait]
282
+ npx driggsby@latest import <FILE> --create-account --account-type <card|bank> --account-name <NAME> [--wait]
283
+ npx driggsby@latest import <ID> [--dates <ORDER> | --amounts <DIRECTION>] [--wait]
284
+ npx driggsby@latest import undo <FILE or ID> [--yes]
285
+ npx driggsby@latest import list
286
+
287
+ Every import names its account: one you have, by its ID (npx driggsby@latest
288
+ accounts list shows each), or a new one by its type and name. Driggsby never
289
+ picks an account for you.
290
+
291
+ How it goes:
292
+ 1. import <FILE> uploads the file and returns right away with an ID.
293
+ 2. import <ID> --wait waits up to 90 seconds for the next milestone:
294
+ the transactions are in, a question, a stop, or categories done.
295
+ 3. If Driggsby needs a fact it can't read from the file, like which way
296
+ round its dates are, the output asks and lists one command per answer.
297
+ 4. Once the transactions are in, categories fill in on their own. If
298
+ you need them, run --wait again; each run shows how far along.
299
+
300
+ Safe to repeat: importing the same file again shows where it stands and
301
+ never imports it twice. Transactions already in the account are skipped,
302
+ so overlapping exports are fine. A new account's name must be one no
303
+ account has: after the first import, name that account by its ID.
304
+
305
+ Options:
306
+ --account <ID> The account to import into, by its ID
307
+ --create-account Import into a new account, made by this import
308
+ --account-type <TYPE> With --create-account: card or bank
309
+ --account-name <NAME> With --create-account: the new account's name
310
+ --dates <ORDER> Answer its question: day-first or month-first
311
+ --amounts <DIRECTION> Answer its question: positive-is-out or positive-is-in
312
+ --wait Wait up to 90 seconds for the next milestone
313
+ --yes With undo: undo without asking
314
+ -h, --help Print help
315
+
316
+ Files: CSV, up to 10 MB and 25,000 rows.
317
+
318
+ Examples:
319
+ npx driggsby@latest accounts list
320
+ npx driggsby@latest import statement.csv --account acct_7kq2
321
+ npx driggsby@latest import statement.csv --create-account --account-type card --account-name "Everyday Card"
322
+ npx driggsby@latest import list
323
+ `;
324
+ export const ACCOUNTS_HELP = `List your Driggsby accounts and the IDs an import names.
325
+
326
+ Sign in first with npx driggsby@latest login.
327
+
328
+ Usage:
329
+ npx driggsby@latest accounts list
330
+
331
+ Options:
332
+ -h, --help Print help
333
+
334
+ Examples:
335
+ npx driggsby@latest accounts list
336
+ npx driggsby@latest import statement.csv --account acct_7kq2
337
+ `;
272
338
  export const MCP_HELP = `Set up Driggsby MCP clients.
273
339
 
274
340
  Usage: npx driggsby@latest mcp <COMMAND>
@@ -0,0 +1,215 @@
1
+ // The import command's channel to Driggsby: one upload for a new file's
2
+ // bytes (POST /statement-imports), and the CLI-only statement-import tools
3
+ // on /mcp for everything else. The file never rides a tool call. Every
4
+ // request carries the saved sign-in, which requireDeploySession has already
5
+ // pinned to Driggsby itself, and never follows a redirect.
6
+ import { CliError } from "../cli-error.js";
7
+ import { SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
8
+ import { callRuleTool } from "../rules/rules-rpc.js";
9
+ import { capForTerminal } from "../terminal-text.js";
10
+ import { asRecord, readImportView, readUndoPreview, refText } from "./import-view.js";
11
+ // With a lookup ahead of it, inside a coding agent's 2-minute limit on one
12
+ // command; a 10 MB file needs about 1 Mbit/s of upload to finish.
13
+ const UPLOAD_TIMEOUT_MS = 90_000;
14
+ // Each tool call answers in well under a second; this only bounds a hung one.
15
+ const TOOL_TIMEOUT_MS = 15_000;
16
+ const MAX_SERVER_MESSAGE_CHARS = 1_000;
17
+ // Far past anyone's accounts; only bounds a server that never stops paging.
18
+ const ACCOUNT_PAGES = 20;
19
+ export const IMPORTS_NOT_AVAILABLE_MESSAGE = "This sign-in can't import statements. Sign in again and approve the request:\n" + " npx driggsby@latest login";
20
+ // An answer the CLI can't read; the command adds the same command to run again.
21
+ export const TROUBLE = "Driggsby couldn't take that just now. Please try again in a minute.";
22
+ // Driggsby said it was busy, timed out, or was briefly unavailable: the same
23
+ // command again is the fix, which the command names, whatever `retryable`
24
+ // says (every import command is safe to repeat). A limit that lasts longer
25
+ // (a day's) is rate_limited with retryable: false and keeps its own words.
26
+ export class DriggsbyBusy extends Error {
27
+ }
28
+ const TEMPORARY_KINDS = new Set(["busy", "timeout", "unavailable"]);
29
+ function temporary(details) {
30
+ if (details === null)
31
+ return false;
32
+ if (details.kind === "rate_limited")
33
+ return details.retryable === true;
34
+ return details.retryable === true || (typeof details.kind === "string" && TEMPORARY_KINDS.has(details.kind));
35
+ }
36
+ // Where the file is meant to go rides along, so an import of it that went
37
+ // to another account comes back refused, with that import.
38
+ export async function findByFile(session, sha256, destination) {
39
+ return tool(session, "get_statement_import", { sha256, ...destinationArguments(destination) });
40
+ }
41
+ function destinationArguments(destination) {
42
+ if (destination === null)
43
+ return {};
44
+ return "account" in destination
45
+ ? { account: destination.account }
46
+ : { new_account_type: destination.create.type, new_account_name: destination.create.name };
47
+ }
48
+ export async function findByRef(session, importRef) {
49
+ return tool(session, "get_statement_import", { import_ref: importRef });
50
+ }
51
+ export async function answer(session, importRef, given) {
52
+ return tool(session, "answer_statement_import", { import_ref: importRef, ...given });
53
+ }
54
+ export async function undo(session, importRef, confirm) {
55
+ return tool(session, "undo_statement_import", confirm ? { import_ref: importRef, confirm: true } : { import_ref: importRef });
56
+ }
57
+ export async function list(session) {
58
+ const outcome = await callRuleTool(session, "list_statement_imports", {}, TOOL_TIMEOUT_MS);
59
+ if (outcome.kind === "refused" && temporary(asRecord(outcome.details))) {
60
+ throw new DriggsbyBusy();
61
+ }
62
+ if (outcome.kind !== "ok") {
63
+ throw new CliError(outcome.kind === "error" ? notAvailable(outcome.message) : outcome.message, 1);
64
+ }
65
+ const imports = asRecord(outcome.result)?.imports;
66
+ if (!Array.isArray(imports)) {
67
+ throw new CliError(TROUBLE, 1);
68
+ }
69
+ const entries = imports;
70
+ return entries.flatMap((entry) => {
71
+ const view = readImportView(entry);
72
+ return view === null ? [] : [view];
73
+ });
74
+ }
75
+ // The person's accounts, every page of them.
76
+ export async function accounts(session) {
77
+ const rows = [];
78
+ let pageToken = null;
79
+ for (let page = 0; page < ACCOUNT_PAGES; page += 1) {
80
+ const outcome = await callRuleTool(session, "list_accounts", pageToken === null ? {} : { page_token: pageToken }, TOOL_TIMEOUT_MS);
81
+ if (outcome.kind === "refused" && temporary(asRecord(outcome.details))) {
82
+ throw new DriggsbyBusy();
83
+ }
84
+ if (outcome.kind !== "ok") {
85
+ throw new CliError(outcome.kind === "error" ? notAvailable(outcome.message) : outcome.message, 1);
86
+ }
87
+ const result = asRecord(outcome.result);
88
+ const listed = result?.linked_accounts;
89
+ if (!Array.isArray(listed)) {
90
+ throw new CliError(TROUBLE, 1);
91
+ }
92
+ const entries = listed;
93
+ rows.push(...entries.flatMap((entry) => accountRow(entry)));
94
+ pageToken = typeof result?.next_page_token === "string" && result.next_page_token !== "" ? result.next_page_token : null;
95
+ if (pageToken === null)
96
+ break;
97
+ }
98
+ return rows;
99
+ }
100
+ function accountRow(value) {
101
+ const record = asRecord(value);
102
+ const ref = refText(record?.account_ref);
103
+ const name = typeof record?.account_display_name === "string" ? record.account_display_name : null;
104
+ if (ref === null || name === null) {
105
+ return [];
106
+ }
107
+ const last4 = typeof record?.account_mask_last4 === "string" && /^\d{4}$/.test(record.account_mask_last4) ? record.account_mask_last4 : null;
108
+ return [{ ref, name, type: typeof record?.account_type === "string" ? record.account_type : "", last4 }];
109
+ }
110
+ export async function upload(session, file, destination) {
111
+ // Bare headers on purpose, as for /mcp: Driggsby refuses anything that
112
+ // looks like a browser call, and Node's fetch adds no Origin of its own.
113
+ const headers = {
114
+ "Content-Type": "application/octet-stream",
115
+ Authorization: `Bearer ${session.token}`,
116
+ "Driggsby-Filename": encodeURIComponent(file.name),
117
+ };
118
+ if ("account" in destination) {
119
+ headers["Driggsby-Account"] = destination.account;
120
+ }
121
+ else {
122
+ headers["Driggsby-New-Account-Type"] = destination.create.type;
123
+ headers["Driggsby-New-Account-Name"] = encodeURIComponent(destination.create.name);
124
+ }
125
+ const response = await fetch(new URL("/statement-imports", session.baseUrl), {
126
+ method: "POST",
127
+ headers,
128
+ body: file.bytes,
129
+ redirect: "error",
130
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
131
+ });
132
+ if (response.status === 401) {
133
+ throw new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
134
+ }
135
+ // A minute's limit, or Driggsby (or the way to it) briefly down.
136
+ if (response.status === 429 || response.status >= 500) {
137
+ throw new DriggsbyBusy();
138
+ }
139
+ const payload = await jsonBody(response);
140
+ const record = asRecord(payload);
141
+ if (response.status === 403 && record?.error === "cli_sign_in_required") {
142
+ throw new CliError(IMPORTS_NOT_AVAILABLE_MESSAGE, 1);
143
+ }
144
+ if (response.ok) {
145
+ const view = readImportView(record?.import);
146
+ if (view === null) {
147
+ throw new CliError(TROUBLE, 1);
148
+ }
149
+ return { kind: "import", view, created: response.status === 201, note: null, undo: null };
150
+ }
151
+ if ((response.status === 422 || response.status === 413) && typeof record?.error === "string") {
152
+ return {
153
+ kind: "refused",
154
+ code: record.error,
155
+ message: serverMessage(record.error_description),
156
+ account: takenAccount(record.account),
157
+ view: readImportView(record.import),
158
+ };
159
+ }
160
+ throw new CliError(TROUBLE, 1);
161
+ }
162
+ async function tool(session, name, toolArguments) {
163
+ const outcome = await callRuleTool(session, name, toolArguments, TOOL_TIMEOUT_MS);
164
+ if (outcome.kind === "error") {
165
+ throw new CliError(notAvailable(outcome.message), 1);
166
+ }
167
+ const details = asRecord(outcome.kind === "ok" ? outcome.result : outcome.details);
168
+ if (outcome.kind === "refused" && temporary(details)) {
169
+ throw new DriggsbyBusy();
170
+ }
171
+ if (outcome.kind === "refused") {
172
+ return {
173
+ kind: "refused",
174
+ code: typeof details?.code === "string" ? details.code : "refused",
175
+ message: outcome.message,
176
+ account: takenAccount(details?.account),
177
+ view: readImportView(details?.import),
178
+ };
179
+ }
180
+ const view = readImportView(details?.import);
181
+ if (view === null) {
182
+ throw new CliError(TROUBLE, 1);
183
+ }
184
+ return {
185
+ kind: "import",
186
+ view,
187
+ created: false,
188
+ note: typeof details?.note === "string" ? serverMessage(details.note) : null,
189
+ undo: readUndoPreview(details?.undo),
190
+ };
191
+ }
192
+ // The account a new account's name already belongs to, when Driggsby says.
193
+ function takenAccount(value) {
194
+ const record = asRecord(value);
195
+ const ref = refText(record?.account_ref);
196
+ const type = typeof record?.account_type === "string" ? record.account_type : "";
197
+ return ref === null ? null : { ref, name: serverMessage(record?.name), type, last4: null };
198
+ }
199
+ // A sign-in from before imports (or an app's token) is told how to get them;
200
+ // any other protocol error keeps its own words.
201
+ function notAvailable(message) {
202
+ return /isn't available from the Driggsby CLI|Only a Driggsby CLI sign-in|Unknown tool/.test(message) ? IMPORTS_NOT_AVAILABLE_MESSAGE : message;
203
+ }
204
+ async function jsonBody(response) {
205
+ try {
206
+ const parsed = await response.json();
207
+ return parsed;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ function serverMessage(value) {
214
+ return typeof value === "string" ? capForTerminal(value, MAX_SERVER_MESSAGE_CHARS).trim() : "";
215
+ }