@oliverames/ynab-mcp-server 1.4.0 → 1.7.1
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 +14 -4
- package/index.js +156 -20
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="
|
|
2
|
+
<img src="assets/icon.png" width="80" height="80" alt="YNAB">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">YNAB MCP Server</h1>
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
<a href="https://www.npmjs.com/package/@oliverames/ynab-mcp-server"><img src="https://img.shields.io/npm/v/%40oliverames%2Fynab-mcp-server?style=flat-square&color=f5a542" alt="npm"></a>
|
|
20
|
+
<a href="https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0"><img src="https://img.shields.io/github/v/release/oliverames/ynab-mcp-server?style=flat-square&color=f5a542&label=MCPB" alt="MCPB release"></a>
|
|
20
21
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
|
|
21
22
|
<a href="https://www.buymeacoffee.com/oliverames"><img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-f5a542?style=flat-square&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee"></a>
|
|
22
23
|
</p>
|
|
23
24
|
|
|
24
25
|
<p align="center">
|
|
25
26
|
<a href="#quick-start">Quick Start</a> •
|
|
27
|
+
<a href="#install-with-mcpb">MCPB Download</a> •
|
|
26
28
|
<a href="#what-you-can-do">What You Can Do</a> •
|
|
27
29
|
<a href="#tools-reference">All 44 Tools</a> •
|
|
28
30
|
<a href="#environment-variables">Configuration</a>
|
|
@@ -40,6 +42,14 @@ This server gives your AI assistant full access to YNAB's API, turning natural l
|
|
|
40
42
|
|
|
41
43
|
## Quick Start
|
|
42
44
|
|
|
45
|
+
### Install with MCPB
|
|
46
|
+
|
|
47
|
+
For Claude Desktop and other MCPB-compatible clients, download the local bundle from the [v1.4.0 release](https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0):
|
|
48
|
+
|
|
49
|
+
[Download `ynab-mcp-server-1.4.0.mcpb`](https://github.com/oliverames/ynab-mcp-server/releases/download/v1.4.0/ynab-mcp-server-1.4.0.mcpb)
|
|
50
|
+
|
|
51
|
+
The bundle includes the YNAB favicon, production runtime dependencies, and setup prompts for your personal access token and optional default budget ID.
|
|
52
|
+
|
|
43
53
|
### 1. Get a YNAB Personal Access Token
|
|
44
54
|
|
|
45
55
|
Go to [YNAB Developer Settings](https://app.ynab.com/settings/developer) and create a new personal access token.
|
|
@@ -144,7 +154,7 @@ That's it. Your AI can now talk to YNAB.
|
|
|
144
154
|
- **Bulk operations** - `create_transactions` and `update_transactions` handle arrays in a single API call.
|
|
145
155
|
- **Fetch-then-merge updates** - scheduled transaction updates (which use PUT semantics) automatically fetch the current state and merge your changes, so you only specify what changed.
|
|
146
156
|
- **Fuzzy search** - `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
|
|
147
|
-
- **Approval workflow** - `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized),
|
|
157
|
+
- **Approval workflow with anomaly flags** - `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), and attaches a `flags` array to each transaction surfacing anomalies: `manually_entered` (not bank-imported), `match_broken` (stale match reference), `scheduled_transaction_realized`, `new_payee`, `no_prior_amount_match` (novel amount for this payee), and `category_drift:was_X` (payee categorized differently in the prior 60 days). Group-level flags aggregate the union of all transaction flags.
|
|
148
158
|
- **Nullable updates** - update tools accept `null` for clearable fields (`memo`, `payeeName`, `categoryId`, `flagColor`) to distinguish "don't change" (omit) from "clear this field" (`null`).
|
|
149
159
|
- **Target behavior support** - category create/update tools expose `goalNeedsWholeAmount` for YNAB's "Set aside another" vs. "Refill up to" goal behavior.
|
|
150
160
|
- **Delta request support** - high-volume list tools accept `lastKnowledgeOfServer` and return `server_knowledge` when that parameter is provided.
|
|
@@ -226,7 +236,7 @@ That's it. Your AI can now talk to YNAB.
|
|
|
226
236
|
| Tool | Description |
|
|
227
237
|
|------|-------------|
|
|
228
238
|
| `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
|
|
229
|
-
| `get_transaction` | Get a single transaction by ID (includes subtransactions) |
|
|
239
|
+
| `get_transaction` | Get a single transaction by ID (includes subtransactions). Auto-handles composite scheduled-transaction IDs like `uuid_YYYY-MM-DD`. |
|
|
230
240
|
| `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
|
|
231
241
|
| `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
|
|
232
242
|
| `update_transaction` | Partial update - only specified fields change |
|
|
@@ -250,7 +260,7 @@ That's it. Your AI can now talk to YNAB.
|
|
|
250
260
|
|
|
251
261
|
| Tool | Description |
|
|
252
262
|
|------|-------------|
|
|
253
|
-
| `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Includes a warning against blind approval. |
|
|
263
|
+
| `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Each transaction includes a `flags` array highlighting anomalies (manually_entered, match_broken, no_prior_amount_match, category_drift, new_payee, scheduled_transaction_realized) computed against 60 days of payee history. Includes a warning against blind approval. |
|
|
254
264
|
|
|
255
265
|
---
|
|
256
266
|
|
package/index.js
CHANGED
|
@@ -101,6 +101,12 @@ function mapTransactionUpdate(t) {
|
|
|
101
101
|
return out;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// YNAB scheduled transactions that realize get composite IDs like `uuid_YYYY-MM-DD`.
|
|
105
|
+
// Strip the date suffix so API lookups work correctly.
|
|
106
|
+
function normalizeTransactionId(id) {
|
|
107
|
+
return id.replace(/_\d{4}-\d{2}-\d{2}$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
function ok(data) {
|
|
105
111
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
106
112
|
}
|
|
@@ -151,7 +157,7 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
|
151
157
|
|
|
152
158
|
const server = new McpServer({
|
|
153
159
|
name: "ynab-mcp-server",
|
|
154
|
-
version: "1.
|
|
160
|
+
version: "1.6.0",
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
// ==================== User & Budgets ====================
|
|
@@ -787,7 +793,7 @@ function formatTransaction(t) {
|
|
|
787
793
|
|
|
788
794
|
server.registerTool(
|
|
789
795
|
"get_transactions",
|
|
790
|
-
{ description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.", inputSchema: {
|
|
796
|
+
{ description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month. Each returned transaction includes 'import_payee_name_original' — the raw merchant string from the bank import (e.g. 'AplPay LS ONION RIVEMONTPELIER VT') — which encodes processor flag, merchant name (often longer than the cleaned payee_name), and city+state. This is the primary disambiguation field when payee_name is truncated or ambiguous. Note: large date ranges (6+ months on a busy budget) can return 50KB+ of data; narrow with categoryId/payeeId/month filters when possible.", inputSchema: {
|
|
791
797
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
792
798
|
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
793
799
|
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
@@ -830,13 +836,13 @@ server.registerTool(
|
|
|
830
836
|
|
|
831
837
|
server.registerTool(
|
|
832
838
|
"get_transaction",
|
|
833
|
-
{ description: "Get a single transaction by ID", inputSchema: {
|
|
839
|
+
{ description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
|
|
834
840
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
835
841
|
transactionId: z.string().describe("Transaction ID"),
|
|
836
842
|
} },
|
|
837
843
|
({ budgetId, transactionId }) =>
|
|
838
844
|
run(async () => {
|
|
839
|
-
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
|
|
845
|
+
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
|
|
840
846
|
return ok(formatTransaction(data.transaction));
|
|
841
847
|
})
|
|
842
848
|
);
|
|
@@ -950,7 +956,7 @@ server.registerTool(
|
|
|
950
956
|
|
|
951
957
|
server.registerTool(
|
|
952
958
|
"update_transactions",
|
|
953
|
-
{ description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update.", inputSchema: {
|
|
959
|
+
{ description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update. IMPORTANT: only use transaction IDs extracted from get_transactions / review_unapproved results — never compose IDs by hand (fabricated IDs return 'transaction does not exist in this budget' errors). For combined category+approval changes in one round trip, include both 'categoryId' and 'approved: true' in the same entry. The response returns the full updated transaction objects, which can exceed 50KB for batches over ~50 transactions; if the response overflows, the update still succeeded — verify by counting 'approved: true' occurrences in the saved result file.", inputSchema: {
|
|
954
960
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
955
961
|
transactions: z
|
|
956
962
|
.array(
|
|
@@ -1037,7 +1043,7 @@ function formatScheduledTransaction(t) {
|
|
|
1037
1043
|
|
|
1038
1044
|
server.registerTool(
|
|
1039
1045
|
"list_scheduled_transactions",
|
|
1040
|
-
{ description: "List all scheduled (recurring) transactions", inputSchema: {
|
|
1046
|
+
{ description: "List all scheduled (recurring) transactions. NOTE: only manually-created recurring entries appear here — auto-imported recurring charges (subscriptions, utilities, insurance) are NOT included. Use prior-month transaction history to identify recurring charge timing instead.", inputSchema: {
|
|
1041
1047
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1042
1048
|
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
|
|
1043
1049
|
} },
|
|
@@ -1205,27 +1211,157 @@ server.registerTool(
|
|
|
1205
1211
|
|
|
1206
1212
|
server.registerTool(
|
|
1207
1213
|
"review_unapproved",
|
|
1208
|
-
{ description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Never approve uncategorized transactions without explicit user instruction.
|
|
1209
|
-
|
|
1214
|
+
{ description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Each transaction includes a 'flags' array: manually_entered (not bank-imported), match_broken (matched reference is stale — CANNOT be fixed via this API, requires YNAB web/iOS UI), scheduled_transaction_realized, new_payee (no transaction history for this payee), no_prior_amount_match (novel amount for this payee), category_drift:was_X (payee categorized differently before). Never approve uncategorized transactions without explicit user instruction. For large budgets the full response can exceed 100KB; pass summary:true to get counts + by-payee aggregates without per-transaction detail.", inputSchema: {
|
|
1215
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1216
|
+
summary: z.boolean().optional().describe("If true, omit per-transaction details from the response and return only counts + by-payee aggregates (for both ready_to_approve and needs_category_first). Use this when the full unapproved queue is large; drill into specifics with get_transactions afterwards."),
|
|
1217
|
+
} },
|
|
1218
|
+
({ budgetId, summary }) =>
|
|
1210
1219
|
run(async () => {
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1220
|
+
const bid = resolveBudgetId(budgetId);
|
|
1221
|
+
|
|
1222
|
+
// Fetch unapproved transactions
|
|
1223
|
+
const { data: unapprovedData } = await api.transactions.getTransactions(bid, undefined, "unapproved");
|
|
1224
|
+
const txns = unapprovedData.transactions.map(formatTransaction);
|
|
1225
|
+
const unapprovedIds = new Set(txns.map((t) => t.id));
|
|
1226
|
+
|
|
1227
|
+
// Fetch 60 days of approved history for context
|
|
1228
|
+
const since60 = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1229
|
+
const { data: histData } = await api.transactions.getTransactions(bid, since60);
|
|
1230
|
+
const histTxns = histData.transactions.filter((t) => t.approved && !unapprovedIds.has(t.id));
|
|
1231
|
+
|
|
1232
|
+
// Build payee history lookups (using raw milliunits for history, convert to dollars for the set)
|
|
1233
|
+
const payeeAmounts = {}; // payeeId -> Set of dollar amounts seen
|
|
1234
|
+
const payeeCategories = {}; // payeeId -> Map of categoryId -> categoryName
|
|
1235
|
+
for (const h of histTxns) {
|
|
1236
|
+
if (!h.payee_id) continue;
|
|
1237
|
+
const pid = h.payee_id;
|
|
1238
|
+
const amt = dollars(h.amount);
|
|
1239
|
+
const cid = h.category_id;
|
|
1240
|
+
const cname = h.category_name;
|
|
1241
|
+
if (!payeeAmounts[pid]) payeeAmounts[pid] = new Set();
|
|
1242
|
+
payeeAmounts[pid].add(amt);
|
|
1243
|
+
if (cid) {
|
|
1244
|
+
if (!payeeCategories[pid]) payeeCategories[pid] = new Map();
|
|
1245
|
+
payeeCategories[pid].set(cid, cname);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Attach flags to each unapproved transaction
|
|
1250
|
+
function flagTransaction(t) {
|
|
1251
|
+
const flags = [];
|
|
1252
|
+
const isTransfer = !!t.transfer_account_id;
|
|
1253
|
+
if (!t.import_id && !isTransfer) flags.push("manually_entered");
|
|
1254
|
+
if (t.matched_transaction_id && !t.import_id) flags.push("match_broken");
|
|
1255
|
+
if (/_\d{4}-\d{2}-\d{2}$/.test(t.id)) flags.push("scheduled_transaction_realized");
|
|
1256
|
+
if (t.payee_id) {
|
|
1257
|
+
const hasHistory = !!payeeAmounts[t.payee_id];
|
|
1258
|
+
if (!hasHistory) {
|
|
1259
|
+
flags.push("new_payee");
|
|
1260
|
+
} else {
|
|
1261
|
+
if (!payeeAmounts[t.payee_id].has(t.amount)) flags.push("no_prior_amount_match");
|
|
1262
|
+
if (t.category_id && payeeCategories[t.payee_id] && !payeeCategories[t.payee_id].has(t.category_id)) {
|
|
1263
|
+
const priorNames = [...payeeCategories[t.payee_id].values()].join(", ");
|
|
1264
|
+
flags.push(`category_drift:was_${priorNames}`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return { ...t, flags };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const flaggedTxns = txns.map(flagTransaction);
|
|
1272
|
+
|
|
1213
1273
|
const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
|
|
1214
|
-
|| (t.subtransactions && t.subtransactions.length > 0)
|
|
1215
|
-
|| t.transfer_account_id;
|
|
1274
|
+
|| (t.subtransactions && t.subtransactions.length > 0)
|
|
1275
|
+
|| t.transfer_account_id;
|
|
1216
1276
|
const categorized = [], uncategorized = [];
|
|
1217
|
-
for (const t of
|
|
1277
|
+
for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
|
|
1278
|
+
|
|
1279
|
+
// Group categorized transactions by payee for easier per-group review
|
|
1280
|
+
const byPayee = {};
|
|
1281
|
+
for (const t of categorized) {
|
|
1282
|
+
const key = t.payee_name || "Unknown Payee";
|
|
1283
|
+
if (!byPayee[key]) byPayee[key] = { payee: key, category_name: t.category_name, transactions: [] };
|
|
1284
|
+
byPayee[key].transactions.push(t);
|
|
1285
|
+
}
|
|
1286
|
+
const groups = Object.values(byPayee).map((g) => {
|
|
1287
|
+
// Aggregate flags across all transactions in the group (deduplicated)
|
|
1288
|
+
const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
|
|
1289
|
+
const base = {
|
|
1290
|
+
payee: g.payee,
|
|
1291
|
+
category_name: g.category_name,
|
|
1292
|
+
count: g.transactions.length,
|
|
1293
|
+
total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
|
|
1294
|
+
flags: allFlags,
|
|
1295
|
+
};
|
|
1296
|
+
return summary ? base : { ...base, transactions: g.transactions };
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
// Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
|
|
1300
|
+
const uncategorizedPayload = (() => {
|
|
1301
|
+
if (!summary) return uncategorized;
|
|
1302
|
+
const byPayeeUncat = {};
|
|
1303
|
+
for (const t of uncategorized) {
|
|
1304
|
+
const key = t.payee_name || "Unknown Payee";
|
|
1305
|
+
if (!byPayeeUncat[key]) byPayeeUncat[key] = { payee_name: key, count: 0, total: 0, flags: new Set() };
|
|
1306
|
+
byPayeeUncat[key].count += 1;
|
|
1307
|
+
byPayeeUncat[key].total += t.amount;
|
|
1308
|
+
for (const f of t.flags) byPayeeUncat[key].flags.add(f);
|
|
1309
|
+
}
|
|
1310
|
+
return Object.values(byPayeeUncat).map((g) => ({
|
|
1311
|
+
payee_name: g.payee_name,
|
|
1312
|
+
count: g.count,
|
|
1313
|
+
total: g.total,
|
|
1314
|
+
flags: [...g.flags],
|
|
1315
|
+
}));
|
|
1316
|
+
})();
|
|
1317
|
+
|
|
1318
|
+
const needsCategoryFirst = {
|
|
1319
|
+
count: uncategorized.length,
|
|
1320
|
+
warning: "Do NOT approve these without assigning a category first",
|
|
1321
|
+
};
|
|
1322
|
+
if (summary) {
|
|
1323
|
+
needsCategoryFirst.payees = uncategorizedPayload;
|
|
1324
|
+
} else {
|
|
1325
|
+
needsCategoryFirst.transactions = uncategorizedPayload;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1218
1328
|
return ok({
|
|
1219
|
-
total:
|
|
1329
|
+
total: flaggedTxns.length,
|
|
1330
|
+
summary: !!summary,
|
|
1220
1331
|
ready_to_approve: {
|
|
1221
1332
|
count: categorized.length,
|
|
1222
|
-
|
|
1223
|
-
},
|
|
1224
|
-
needs_category_first: {
|
|
1225
|
-
count: uncategorized.length,
|
|
1226
|
-
warning: "Do NOT approve these without assigning a category first",
|
|
1227
|
-
transactions: uncategorized,
|
|
1333
|
+
by_payee: groups,
|
|
1228
1334
|
},
|
|
1335
|
+
needs_category_first: needsCategoryFirst,
|
|
1336
|
+
});
|
|
1337
|
+
})
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
server.registerTool(
|
|
1341
|
+
"get_overspent_categories",
|
|
1342
|
+
{ description: "Get all categories with a negative balance for a given month. Use this to find prior-month overspends that are silently reducing the current month's Ready to Assign.", inputSchema: {
|
|
1343
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1344
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
1345
|
+
} },
|
|
1346
|
+
({ budgetId, month }) =>
|
|
1347
|
+
run(async () => {
|
|
1348
|
+
const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
|
|
1349
|
+
const overspent = (data.month.categories || [])
|
|
1350
|
+
.filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
|
|
1351
|
+
.map((c) => ({
|
|
1352
|
+
id: c.id,
|
|
1353
|
+
name: c.name,
|
|
1354
|
+
category_group_name: c.category_group_name,
|
|
1355
|
+
budgeted: dollars(c.budgeted),
|
|
1356
|
+
activity: dollars(c.activity),
|
|
1357
|
+
balance: dollars(c.balance),
|
|
1358
|
+
}))
|
|
1359
|
+
.sort((a, b) => a.balance - b.balance);
|
|
1360
|
+
return ok({
|
|
1361
|
+
month,
|
|
1362
|
+
overspent_count: overspent.length,
|
|
1363
|
+
total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
|
|
1364
|
+
categories: overspent,
|
|
1229
1365
|
});
|
|
1230
1366
|
})
|
|
1231
1367
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oliverames/ynab-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "YNAB MCP server with full API coverage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "node index.js",
|
|
15
|
+
"pretest": "[ -d node_modules ] || npm ci --silent --no-audit --no-fund",
|
|
15
16
|
"test": "node test.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|