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 +20 -0
- package/dist/args-import.js +211 -0
- package/dist/args-query.js +20 -2
- package/dist/args-shared.js +1 -0
- package/dist/args.js +19 -2
- package/dist/help.js +66 -0
- package/dist/import/import-client.js +215 -0
- package/dist/import/import-command.js +307 -0
- package/dist/import/import-render.js +334 -0
- package/dist/import/import-target.js +49 -0
- package/dist/import/import-view.js +124 -0
- package/dist/main.js +3 -0
- package/dist/rules/rules-rpc.js +4 -4
- package/package.json +3 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// What `import <FILE or ID>` names: a file on this machine (read once,
|
|
2
|
+
// size-checked, and hashed, so Driggsby can say whether it already has it
|
|
3
|
+
// before any upload), or an import's ID. A file wins over an ID-shaped
|
|
4
|
+
// name, so a file that happens to be called imp_... still imports.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readFile, stat } from "node:fs/promises";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { CliError } from "../cli-error.js";
|
|
9
|
+
import { quotedForTerminal, sanitizeForTerminal, wrapProse } from "../terminal-text.js";
|
|
10
|
+
import {} from "./import-client.js";
|
|
11
|
+
import { isImportRef } from "./import-view.js";
|
|
12
|
+
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
export async function resolveTarget(typed) {
|
|
14
|
+
const info = await stat(typed).catch(() => null);
|
|
15
|
+
if (info === null) {
|
|
16
|
+
if (isImportRef(typed)) {
|
|
17
|
+
return { kind: "ref", ref: typed };
|
|
18
|
+
}
|
|
19
|
+
throw new CliError(wrapProse(`There's no file at ${quotedForTerminal(typed, 200)}. Check the path and run the command again.`), 1);
|
|
20
|
+
}
|
|
21
|
+
if (!info.isFile()) {
|
|
22
|
+
throw new CliError(wrapProse(`${quotedForTerminal(typed, 200)} is a folder, not a file. Name the statement's CSV file itself.`), 1);
|
|
23
|
+
}
|
|
24
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
25
|
+
const megabytes = (info.size / 1024 / 1024).toFixed(1);
|
|
26
|
+
throw new CliError(`Refused: the file is ${megabytes} MB, and statement imports take files up to 10 MB.\n` +
|
|
27
|
+
"Nothing was imported.\n\nNext:\n Export a shorter date range from the bank and import each part.", 1);
|
|
28
|
+
}
|
|
29
|
+
const bytes = new Uint8Array(await readFile(typed));
|
|
30
|
+
return {
|
|
31
|
+
kind: "file",
|
|
32
|
+
file: { name: basename(typed), bytes },
|
|
33
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
34
|
+
typed,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// A file over the limit was never imported, so there's nothing of it to undo.
|
|
38
|
+
export async function tooLargeToImport(typed) {
|
|
39
|
+
const info = await stat(typed).catch(() => null);
|
|
40
|
+
return info !== null && info.isFile() && info.size > MAX_FILE_BYTES;
|
|
41
|
+
}
|
|
42
|
+
// A typed path as a copy-paste-safe shell word: stripped of terminal
|
|
43
|
+
// control bytes, never read as a flag (a leading "-" gets "./"), bare when
|
|
44
|
+
// it's plain, otherwise single-quoted with any single quote spelled out.
|
|
45
|
+
export function shellWord(value) {
|
|
46
|
+
const cleaned = sanitizeForTerminal(value);
|
|
47
|
+
const shown = cleaned.startsWith("-") ? `./${cleaned}` : cleaned;
|
|
48
|
+
return /^[A-Za-z0-9_./@%+=:,-]+$/.test(shown) ? shown : `'${shown.replaceAll("'", "'\\''")}'`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// What Driggsby says about one statement import, read defensively: every
|
|
2
|
+
// field is checked for its type, and anything else is dropped, so a server
|
|
3
|
+
// change can never crash a render or slip an unexpected shape through.
|
|
4
|
+
// Strings are still untrusted text; the renderer sanitizes and quotes them.
|
|
5
|
+
const STATES = new Set([
|
|
6
|
+
"reading",
|
|
7
|
+
"waiting",
|
|
8
|
+
"importing",
|
|
9
|
+
"categorizing",
|
|
10
|
+
"done",
|
|
11
|
+
"stopped",
|
|
12
|
+
"undoing",
|
|
13
|
+
"undone",
|
|
14
|
+
]);
|
|
15
|
+
export const ACCOUNT_REF = /^acct_[A-Za-z0-9_-]{1,64}$/;
|
|
16
|
+
const IMPORT_REF = /^imp_[A-Za-z0-9_-]{12}$/;
|
|
17
|
+
export function isImportRef(value) {
|
|
18
|
+
return IMPORT_REF.test(value);
|
|
19
|
+
}
|
|
20
|
+
export function asRecord(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
22
|
+
}
|
|
23
|
+
function text(value) {
|
|
24
|
+
return typeof value === "string" ? value : null;
|
|
25
|
+
}
|
|
26
|
+
function count(value) {
|
|
27
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
|
28
|
+
}
|
|
29
|
+
function optionalCount(value) {
|
|
30
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
31
|
+
}
|
|
32
|
+
// null when the payload isn't an import Driggsby described.
|
|
33
|
+
export function readImportView(value) {
|
|
34
|
+
const record = asRecord(value);
|
|
35
|
+
const ref = text(record?.import_ref);
|
|
36
|
+
const state = text(record?.state);
|
|
37
|
+
if (record === null || ref === null || !isImportRef(ref) || state === null || !STATES.has(state)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const counts = asRecord(record.counts);
|
|
41
|
+
const dates = asRecord(record.dates);
|
|
42
|
+
const leftOut = asRecord(record.left_out);
|
|
43
|
+
const progress = asRecord(record.progress);
|
|
44
|
+
const stopped = asRecord(record.stopped);
|
|
45
|
+
const from = text(dates?.from);
|
|
46
|
+
const to = text(dates?.to);
|
|
47
|
+
return {
|
|
48
|
+
ref,
|
|
49
|
+
file: text(record.file) ?? "statement.csv",
|
|
50
|
+
state: state,
|
|
51
|
+
lines: optionalCount(record.lines),
|
|
52
|
+
bytes: optionalCount(record.bytes),
|
|
53
|
+
startedAt: text(record.started_at),
|
|
54
|
+
finishedAt: text(record.finished_at),
|
|
55
|
+
account: text(asRecord(record.account)?.label),
|
|
56
|
+
accountRef: refText(asRecord(record.account)?.ref),
|
|
57
|
+
newAccount: record.new_account === true,
|
|
58
|
+
counts: counts === null
|
|
59
|
+
? null
|
|
60
|
+
: { new: count(counts.new), alreadyInDriggsby: count(counts.already_in_driggsby), pending: count(counts.pending) },
|
|
61
|
+
dates: from !== null && to !== null ? { from, to } : null,
|
|
62
|
+
leftOut: { notPosted: count(leftOut?.not_posted), unreadable: count(leftOut?.unreadable), balance: count(leftOut?.balance) },
|
|
63
|
+
nothingNew: text(record.nothing_new),
|
|
64
|
+
categorized: typeof record.categorized === "boolean" ? record.categorized : null,
|
|
65
|
+
question: readQuestion(record.question),
|
|
66
|
+
progress: progress === null
|
|
67
|
+
? null
|
|
68
|
+
: {
|
|
69
|
+
categorized: count(progress.categorized),
|
|
70
|
+
total: count(progress.total),
|
|
71
|
+
secondsLeft: optionalCount(progress.seconds_left),
|
|
72
|
+
},
|
|
73
|
+
stopped: stopped === null ? null : { code: text(stopped.code) ?? "stopped", message: text(stopped.message) ?? "" },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function readQuestion(value) {
|
|
77
|
+
const record = asRecord(value);
|
|
78
|
+
const kind = text(record?.kind);
|
|
79
|
+
const prompt = text(record?.prompt);
|
|
80
|
+
if (record === null || kind === null || prompt === null) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return { kind, prompt, choices: readChoices(record.choices) };
|
|
84
|
+
}
|
|
85
|
+
// Each choice keeps exactly one answer the CLI knows how to send.
|
|
86
|
+
export function readChoices(value) {
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const entries = value;
|
|
91
|
+
return entries.flatMap((entry) => {
|
|
92
|
+
const record = asRecord(entry);
|
|
93
|
+
const label = text(record?.label);
|
|
94
|
+
const answer = asRecord(record?.answer);
|
|
95
|
+
if (label === null || answer === null) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const flags = [
|
|
99
|
+
["--dates", answer.dates],
|
|
100
|
+
["--amounts", answer.amounts],
|
|
101
|
+
];
|
|
102
|
+
const found = flags.filter(([, answerValue]) => typeof answerValue === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(answerValue));
|
|
103
|
+
const [chosen] = found;
|
|
104
|
+
return found.length === 1 && chosen !== undefined ? [{ label, flag: chosen[0], value: chosen[1] }] : [];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function refText(value) {
|
|
108
|
+
return typeof value === "string" && ACCOUNT_REF.test(value) ? value : null;
|
|
109
|
+
}
|
|
110
|
+
export function readUndoPreview(value) {
|
|
111
|
+
const record = asRecord(value);
|
|
112
|
+
if (record === null || typeof record.undoable !== "boolean") {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
undoable: record.undoable,
|
|
117
|
+
cancels: record.cancels === true,
|
|
118
|
+
transactions: count(record.transactions),
|
|
119
|
+
edited: count(record.edited),
|
|
120
|
+
keptForLaterImports: count(record.kept_for_later_imports),
|
|
121
|
+
accountRemoved: record.account_removed === true,
|
|
122
|
+
message: text(record.message),
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runInit } from "./init/init-command.js";
|
|
|
11
11
|
import { runRollback } from "./deploy/rollback-command.js";
|
|
12
12
|
import { runVersions } from "./deploy/versions-command.js";
|
|
13
13
|
import { runQuery } from "./query/query-command.js";
|
|
14
|
+
import { runImport } from "./import/import-command.js";
|
|
14
15
|
import { runItems } from "./items/items-command.js";
|
|
15
16
|
import { runRules } from "./rules/rules-command.js";
|
|
16
17
|
import { runLogin, runLoginWithCode } from "./login/login.js";
|
|
@@ -54,6 +55,8 @@ async function run(argv) {
|
|
|
54
55
|
return await runRules({ action: command.action, params: command.params, yes: command.yes });
|
|
55
56
|
case "items":
|
|
56
57
|
return await runItems({ action: command.action, params: command.params, yes: command.yes });
|
|
58
|
+
case "import":
|
|
59
|
+
return await runImport(command.options);
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
try {
|
package/dist/rules/rules-rpc.js
CHANGED
|
@@ -16,13 +16,13 @@ const MAX_SERVER_MESSAGE_CHARS = 1_000;
|
|
|
16
16
|
export const RULES_NOT_AVAILABLE_MESSAGE = "This sign-in can't use transaction rules. Sign in again and approve the request:\n" +
|
|
17
17
|
" npx driggsby@latest login\n" +
|
|
18
18
|
"If rules still aren't available after that, they aren't open to your account yet.";
|
|
19
|
-
export async function callRuleTool(session, tool, toolArguments) {
|
|
19
|
+
export async function callRuleTool(session, tool, toolArguments, timeoutMs = RULES_CALL_TIMEOUT_MS) {
|
|
20
20
|
const payload = await postMcp(session, {
|
|
21
21
|
jsonrpc: "2.0",
|
|
22
22
|
id: 1,
|
|
23
23
|
method: "tools/call",
|
|
24
24
|
params: { name: tool, arguments: toolArguments },
|
|
25
|
-
});
|
|
25
|
+
}, timeoutMs);
|
|
26
26
|
return interpretToolCall(payload);
|
|
27
27
|
}
|
|
28
28
|
// The rule tools' own descriptions and input schemas, from the tools/list
|
|
@@ -89,7 +89,7 @@ export function interpretToolCall(payload) {
|
|
|
89
89
|
function firstText(content) {
|
|
90
90
|
return Array.isArray(content) ? asRecord(content[0])?.text : undefined;
|
|
91
91
|
}
|
|
92
|
-
async function postMcp(session, body) {
|
|
92
|
+
async function postMcp(session, body, timeoutMs = RULES_CALL_TIMEOUT_MS) {
|
|
93
93
|
// Bare headers on purpose: the Driggsby endpoint refuses anything that
|
|
94
94
|
// looks like a browser call (an Origin or Sec-Fetch-Site header), and
|
|
95
95
|
// Node's fetch sends neither on its own.
|
|
@@ -99,7 +99,7 @@ async function postMcp(session, body) {
|
|
|
99
99
|
body: JSON.stringify(body),
|
|
100
100
|
// The bearer token must never travel to a redirect target.
|
|
101
101
|
redirect: "error",
|
|
102
|
-
signal: AbortSignal.timeout(
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
103
|
});
|
|
104
104
|
if (response.status === 401) {
|
|
105
105
|
throw new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0 AND OFL-1.1",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"node": ">=18"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@driggsby/deploy": "0.
|
|
34
|
-
"@driggsby/sdk": "0.
|
|
33
|
+
"@driggsby/deploy": "0.9.0",
|
|
34
|
+
"@driggsby/sdk": "0.9.0"
|
|
35
35
|
}
|
|
36
36
|
}
|