Skip to content

Commit e34c52f

Browse files
stephendolanclaude
andauthored
chore(release): v2.7.0 (#32)
Add batch transaction updates, field selection consistency, and secure token input. - Add `transactions batch-update` command and MCP tool for updating multiple transactions in a single API call, reducing rate limit pressure (#27) - Add `--fields` support to `accounts transactions`, `categories transactions`, and `payees transactions` commands and their MCP equivalents (#26) - Make `auth login` token input interactive — prompts when no `--token` flag is provided, supports stdin piping for secure automation (#21) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 887489f commit e34c52f

9 files changed

Lines changed: 221 additions & 16 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stephendolan/ynab-cli",
3-
"version": "2.6.0",
3+
"version": "2.7.0",
44
"description": "A command-line interface for You Need a Budget (YNAB)",
55
"type": "module",
66
"main": "./dist/cli.js",

src/commands/accounts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander';
22
import { client } from '../lib/api-client.js';
33
import { outputJson } from '../lib/output.js';
44
import { withErrorHandling } from '../lib/command-utils.js';
5+
import { applyFieldSelection } from '../lib/utils.js';
56
import { parseDate } from '../lib/dates.js';
67
import type { CommandOptions } from '../types/index.js';
78

@@ -38,6 +39,10 @@ export function createAccountsCommand(): Command {
3839
.option('-b, --budget <id>', 'Budget ID')
3940
.option('--since <date>', 'Filter transactions since date')
4041
.option('--type <type>', 'Filter by transaction type')
42+
.option(
43+
'--fields <fields>',
44+
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
45+
)
4146
.action(
4247
withErrorHandling(
4348
async (
@@ -46,14 +51,16 @@ export function createAccountsCommand(): Command {
4651
budget?: string;
4752
since?: string;
4853
type?: string;
54+
fields?: string;
4955
} & CommandOptions
5056
) => {
5157
const result = await client.getTransactionsByAccount(id, {
5258
budgetId: options.budget,
5359
sinceDate: options.since ? parseDate(options.since) : undefined,
5460
type: options.type,
5561
});
56-
outputJson(result?.transactions);
62+
const transactions = result?.transactions || [];
63+
outputJson(applyFieldSelection(transactions, options.fields));
5764
}
5865
)
5966
);

src/commands/auth.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,54 @@
11
import { Command } from 'commander';
2+
import { createInterface } from 'readline';
23
import { auth } from '../lib/auth.js';
34
import { outputJson } from '../lib/output.js';
45
import { client } from '../lib/api-client.js';
56
import { withErrorHandling } from '../lib/command-utils.js';
67
import { YnabCliError } from '../lib/errors.js';
78

9+
function readTokenFromStdin(): Promise<string> {
10+
return new Promise((resolve, reject) => {
11+
let data = '';
12+
process.stdin.setEncoding('utf8');
13+
process.stdin.on('data', (chunk) => { data += chunk; });
14+
process.stdin.on('end', () => resolve(data.trim()));
15+
process.stdin.on('error', reject);
16+
});
17+
}
18+
19+
function promptForToken(): Promise<string> {
20+
return new Promise((resolve) => {
21+
const rl = createInterface({
22+
input: process.stdin,
23+
output: process.stderr,
24+
});
25+
process.stderr.write('Enter YNAB Personal Access Token: ');
26+
rl.question('', (answer) => {
27+
rl.close();
28+
resolve(answer.trim());
29+
});
30+
});
31+
}
32+
833
export function createAuthCommand(): Command {
934
const cmd = new Command('auth').description('Authentication management');
1035

1136
cmd
1237
.command('login')
1338
.description('Configure access token')
14-
.requiredOption('-t, --token <token>', 'YNAB Personal Access Token')
39+
.option('-t, --token <token>', 'YNAB Personal Access Token')
1540
.action(
16-
withErrorHandling(async (options: { token: string }) => {
17-
const token = options.token.trim();
41+
withErrorHandling(async (options: { token?: string }) => {
42+
let token: string;
43+
44+
if (options.token) {
45+
token = options.token.trim();
46+
} else if (!process.stdin.isTTY) {
47+
token = await readTokenFromStdin();
48+
} else {
49+
token = await promptForToken();
50+
}
51+
1852
if (!token) {
1953
throw new YnabCliError('Access token cannot be empty', 400);
2054
}

src/commands/categories.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Command } from 'commander';
22
import { client } from '../lib/api-client.js';
33
import { outputJson } from '../lib/output.js';
44
import { YnabCliError } from '../lib/errors.js';
5-
import { amountToMilliunits } from '../lib/utils.js';
5+
import { amountToMilliunits, applyFieldSelection } from '../lib/utils.js';
66
import { withErrorHandling } from '../lib/command-utils.js';
77
import { parseDate } from '../lib/dates.js';
88
import type { CommandOptions } from '../types/index.js';
@@ -135,6 +135,10 @@ export function createCategoriesCommand(): Command {
135135
.option('--since <date>', 'Filter transactions since date')
136136
.option('--type <type>', 'Filter by transaction type')
137137
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
138+
.option(
139+
'--fields <fields>',
140+
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
141+
)
138142
.action(
139143
withErrorHandling(
140144
async (
@@ -144,6 +148,7 @@ export function createCategoriesCommand(): Command {
144148
since?: string;
145149
type?: string;
146150
lastKnowledge?: number;
151+
fields?: string;
147152
} & CommandOptions
148153
) => {
149154
const result = await client.getTransactionsByCategory(id, {
@@ -152,7 +157,8 @@ export function createCategoriesCommand(): Command {
152157
type: options.type,
153158
lastKnowledgeOfServer: options.lastKnowledge,
154159
});
155-
outputJson(result?.transactions);
160+
const transactions = result?.transactions || [];
161+
outputJson(applyFieldSelection(transactions, options.fields));
156162
}
157163
)
158164
);

src/commands/payees.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { client } from '../lib/api-client.js';
33
import { outputJson } from '../lib/output.js';
44
import { YnabCliError } from '../lib/errors.js';
55
import { withErrorHandling } from '../lib/command-utils.js';
6+
import { applyFieldSelection } from '../lib/utils.js';
67
import { parseDate } from '../lib/dates.js';
78
import type { CommandOptions } from '../types/index.js';
89

@@ -78,6 +79,10 @@ export function createPayeesCommand(): Command {
7879
.option('--since <date>', 'Filter transactions since date')
7980
.option('--type <type>', 'Filter by transaction type')
8081
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
82+
.option(
83+
'--fields <fields>',
84+
'Comma-separated list of fields to include (e.g., id,date,amount,memo)'
85+
)
8186
.action(
8287
withErrorHandling(
8388
async (
@@ -87,6 +92,7 @@ export function createPayeesCommand(): Command {
8792
since?: string;
8893
type?: string;
8994
lastKnowledge?: number;
95+
fields?: string;
9096
} & CommandOptions
9197
) => {
9298
const result = await client.getTransactionsByPayee(id, {
@@ -95,7 +101,8 @@ export function createPayeesCommand(): Command {
95101
type: options.type,
96102
lastKnowledgeOfServer: options.lastKnowledge,
97103
});
98-
outputJson(result?.transactions);
104+
const transactions = result?.transactions || [];
105+
outputJson(applyFieldSelection(transactions, options.fields));
99106
}
100107
)
101108
);

src/commands/transactions.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type TransactionLike,
1010
} from '../lib/utils.js';
1111
import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js';
12-
import { validateTransactionSplits } from '../lib/schemas.js';
12+
import { validateTransactionSplits, validateBatchUpdates } from '../lib/schemas.js';
1313
import { parseDate, todayDate } from '../lib/dates.js';
1414
import type { CommandOptions } from '../types/index.js';
1515

@@ -322,6 +322,44 @@ export function createTransactionsCommand(): Command {
322322
)
323323
);
324324

325+
cmd
326+
.command('batch-update')
327+
.description(
328+
'Update multiple transactions in a single API call. Amounts should be in dollars (e.g., -21.40).'
329+
)
330+
.requiredOption(
331+
'--transactions <json>',
332+
'JSON array of transaction updates. Each must have "id" or "import_id". Example: [{"id": "tx1", "approved": true, "category_id": "cat1"}]'
333+
)
334+
.option('-b, --budget <id>', 'Budget ID')
335+
.action(
336+
withErrorHandling(
337+
async (options: { transactions: string; budget?: string } & CommandOptions) => {
338+
let parsed;
339+
try {
340+
parsed = JSON.parse(options.transactions);
341+
} catch {
342+
throw new YnabCliError('Invalid JSON in --transactions parameter', 400);
343+
}
344+
345+
const updates = validateBatchUpdates(parsed);
346+
347+
const transactionsInMilliunits = updates.map((update) => ({
348+
...update,
349+
...(update.amount !== undefined
350+
? { amount: amountToMilliunits(update.amount) }
351+
: {}),
352+
}));
353+
354+
const result = await client.updateTransactions(
355+
{ transactions: transactionsInMilliunits as Parameters<typeof client.updateTransactions>[0]['transactions'] },
356+
options.budget
357+
);
358+
outputJson(result);
359+
}
360+
)
361+
);
362+
325363
cmd
326364
.command('search')
327365
.description('Search transactions')

src/lib/api-client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,22 @@ export class YnabClient {
359359
});
360360
}
361361

362+
async updateTransactions(
363+
transactions: ynab.PatchTransactionsWrapper,
364+
budgetId?: string
365+
) {
366+
return this.withErrorHandling(async () => {
367+
const api = await this.getApi();
368+
const id = await this.getBudgetId(budgetId);
369+
const response = await api.transactions.updateTransactions(id, transactions);
370+
return {
371+
transactions: response.data.transactions,
372+
transaction_ids: response.data.transaction_ids,
373+
server_knowledge: response.data.server_knowledge,
374+
};
375+
});
376+
}
377+
362378
async deleteTransaction(transactionId: string, budgetId?: string) {
363379
return this.withErrorHandling(async () => {
364380
const api = await this.getApi();

src/lib/schemas.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,56 @@ export function validateTransactionSplits(data: unknown): TransactionSplit[] {
3232
});
3333
}
3434

35+
export interface BatchTransactionUpdate {
36+
id?: string | null;
37+
import_id?: string | null;
38+
account_id?: string;
39+
date?: string;
40+
amount?: number;
41+
payee_id?: string | null;
42+
payee_name?: string | null;
43+
category_id?: string | null;
44+
memo?: string | null;
45+
cleared?: string;
46+
approved?: boolean;
47+
flag_color?: string | null;
48+
}
49+
50+
const BATCH_UPDATE_FIELDS: (keyof BatchTransactionUpdate)[] = [
51+
'id', 'import_id', 'account_id', 'date', 'amount',
52+
'payee_id', 'payee_name', 'category_id', 'memo',
53+
'cleared', 'approved', 'flag_color',
54+
];
55+
56+
export function validateBatchUpdates(data: unknown): BatchTransactionUpdate[] {
57+
if (!Array.isArray(data)) {
58+
throw new YnabCliError('Batch updates must be an array', 400);
59+
}
60+
61+
return data.map((item, index) => {
62+
if (typeof item !== 'object' || item === null) {
63+
throw new YnabCliError(`Update at index ${index} must be an object`, 400);
64+
}
65+
66+
const update = item as Record<string, unknown>;
67+
68+
if (!update.id && !update.import_id) {
69+
throw new YnabCliError(
70+
`Update at index ${index} must have either "id" or "import_id"`,
71+
400
72+
);
73+
}
74+
75+
const result: Record<string, unknown> = {};
76+
for (const field of BATCH_UPDATE_FIELDS) {
77+
if (update[field] !== undefined) {
78+
result[field] = update[field];
79+
}
80+
}
81+
return result as BatchTransactionUpdate;
82+
});
83+
}
84+
3585
export function validateApiData(data: unknown): Record<string, unknown> {
3686
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
3787
throw new YnabCliError('API data must be an object', 400);

0 commit comments

Comments
 (0)