diff --git a/package.json b/package.json index e6e3916..623df02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stephendolan/ynab-cli", - "version": "2.6.0", + "version": "2.7.0", "description": "A command-line interface for You Need a Budget (YNAB)", "type": "module", "main": "./dist/cli.js", diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index e3f5f69..3fecd8b 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { client } from '../lib/api-client.js'; import { outputJson } from '../lib/output.js'; import { withErrorHandling } from '../lib/command-utils.js'; +import { applyFieldSelection } from '../lib/utils.js'; import { parseDate } from '../lib/dates.js'; import type { CommandOptions } from '../types/index.js'; @@ -38,6 +39,10 @@ export function createAccountsCommand(): Command { .option('-b, --budget ', 'Budget ID') .option('--since ', 'Filter transactions since date') .option('--type ', 'Filter by transaction type') + .option( + '--fields ', + 'Comma-separated list of fields to include (e.g., id,date,amount,memo)' + ) .action( withErrorHandling( async ( @@ -46,6 +51,7 @@ export function createAccountsCommand(): Command { budget?: string; since?: string; type?: string; + fields?: string; } & CommandOptions ) => { const result = await client.getTransactionsByAccount(id, { @@ -53,7 +59,8 @@ export function createAccountsCommand(): Command { sinceDate: options.since ? parseDate(options.since) : undefined, type: options.type, }); - outputJson(result?.transactions); + const transactions = result?.transactions || []; + outputJson(applyFieldSelection(transactions, options.fields)); } ) ); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 7c7d907..6fadfb9 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,20 +1,54 @@ import { Command } from 'commander'; +import { createInterface } from 'readline'; import { auth } from '../lib/auth.js'; import { outputJson } from '../lib/output.js'; import { client } from '../lib/api-client.js'; import { withErrorHandling } from '../lib/command-utils.js'; import { YnabCliError } from '../lib/errors.js'; +function readTokenFromStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => resolve(data.trim())); + process.stdin.on('error', reject); + }); +} + +function promptForToken(): Promise { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + process.stderr.write('Enter YNAB Personal Access Token: '); + rl.question('', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + export function createAuthCommand(): Command { const cmd = new Command('auth').description('Authentication management'); cmd .command('login') .description('Configure access token') - .requiredOption('-t, --token ', 'YNAB Personal Access Token') + .option('-t, --token ', 'YNAB Personal Access Token') .action( - withErrorHandling(async (options: { token: string }) => { - const token = options.token.trim(); + withErrorHandling(async (options: { token?: string }) => { + let token: string; + + if (options.token) { + token = options.token.trim(); + } else if (!process.stdin.isTTY) { + token = await readTokenFromStdin(); + } else { + token = await promptForToken(); + } + if (!token) { throw new YnabCliError('Access token cannot be empty', 400); } diff --git a/src/commands/categories.ts b/src/commands/categories.ts index cdc9c72..5f8cee6 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { client } from '../lib/api-client.js'; import { outputJson } from '../lib/output.js'; import { YnabCliError } from '../lib/errors.js'; -import { amountToMilliunits } from '../lib/utils.js'; +import { amountToMilliunits, applyFieldSelection } from '../lib/utils.js'; import { withErrorHandling } from '../lib/command-utils.js'; import { parseDate } from '../lib/dates.js'; import type { CommandOptions } from '../types/index.js'; @@ -135,6 +135,10 @@ export function createCategoriesCommand(): Command { .option('--since ', 'Filter transactions since date') .option('--type ', 'Filter by transaction type') .option('--last-knowledge ', 'Last knowledge of server', parseInt) + .option( + '--fields ', + 'Comma-separated list of fields to include (e.g., id,date,amount,memo)' + ) .action( withErrorHandling( async ( @@ -144,6 +148,7 @@ export function createCategoriesCommand(): Command { since?: string; type?: string; lastKnowledge?: number; + fields?: string; } & CommandOptions ) => { const result = await client.getTransactionsByCategory(id, { @@ -152,7 +157,8 @@ export function createCategoriesCommand(): Command { type: options.type, lastKnowledgeOfServer: options.lastKnowledge, }); - outputJson(result?.transactions); + const transactions = result?.transactions || []; + outputJson(applyFieldSelection(transactions, options.fields)); } ) ); diff --git a/src/commands/payees.ts b/src/commands/payees.ts index 13c2e85..de42c9c 100644 --- a/src/commands/payees.ts +++ b/src/commands/payees.ts @@ -3,6 +3,7 @@ import { client } from '../lib/api-client.js'; import { outputJson } from '../lib/output.js'; import { YnabCliError } from '../lib/errors.js'; import { withErrorHandling } from '../lib/command-utils.js'; +import { applyFieldSelection } from '../lib/utils.js'; import { parseDate } from '../lib/dates.js'; import type { CommandOptions } from '../types/index.js'; @@ -78,6 +79,10 @@ export function createPayeesCommand(): Command { .option('--since ', 'Filter transactions since date') .option('--type ', 'Filter by transaction type') .option('--last-knowledge ', 'Last knowledge of server', parseInt) + .option( + '--fields ', + 'Comma-separated list of fields to include (e.g., id,date,amount,memo)' + ) .action( withErrorHandling( async ( @@ -87,6 +92,7 @@ export function createPayeesCommand(): Command { since?: string; type?: string; lastKnowledge?: number; + fields?: string; } & CommandOptions ) => { const result = await client.getTransactionsByPayee(id, { @@ -95,7 +101,8 @@ export function createPayeesCommand(): Command { type: options.type, lastKnowledgeOfServer: options.lastKnowledge, }); - outputJson(result?.transactions); + const transactions = result?.transactions || []; + outputJson(applyFieldSelection(transactions, options.fields)); } ) ); diff --git a/src/commands/transactions.ts b/src/commands/transactions.ts index 9232b03..badacc9 100644 --- a/src/commands/transactions.ts +++ b/src/commands/transactions.ts @@ -9,7 +9,7 @@ import { type TransactionLike, } from '../lib/utils.js'; import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js'; -import { validateTransactionSplits } from '../lib/schemas.js'; +import { validateTransactionSplits, validateBatchUpdates } from '../lib/schemas.js'; import { parseDate, todayDate } from '../lib/dates.js'; import type { CommandOptions } from '../types/index.js'; @@ -322,6 +322,44 @@ export function createTransactionsCommand(): Command { ) ); + cmd + .command('batch-update') + .description( + 'Update multiple transactions in a single API call. Amounts should be in dollars (e.g., -21.40).' + ) + .requiredOption( + '--transactions ', + 'JSON array of transaction updates. Each must have "id" or "import_id". Example: [{"id": "tx1", "approved": true, "category_id": "cat1"}]' + ) + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async (options: { transactions: string; budget?: string } & CommandOptions) => { + let parsed; + try { + parsed = JSON.parse(options.transactions); + } catch { + throw new YnabCliError('Invalid JSON in --transactions parameter', 400); + } + + const updates = validateBatchUpdates(parsed); + + const transactionsInMilliunits = updates.map((update) => ({ + ...update, + ...(update.amount !== undefined + ? { amount: amountToMilliunits(update.amount) } + : {}), + })); + + const result = await client.updateTransactions( + { transactions: transactionsInMilliunits as Parameters[0]['transactions'] }, + options.budget + ); + outputJson(result); + } + ) + ); + cmd .command('search') .description('Search transactions') diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f6bf6f8..391e2ee 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -359,6 +359,22 @@ export class YnabClient { }); } + async updateTransactions( + transactions: ynab.PatchTransactionsWrapper, + budgetId?: string + ) { + return this.withErrorHandling(async () => { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.transactions.updateTransactions(id, transactions); + return { + transactions: response.data.transactions, + transaction_ids: response.data.transaction_ids, + server_knowledge: response.data.server_knowledge, + }; + }); + } + async deleteTransaction(transactionId: string, budgetId?: string) { return this.withErrorHandling(async () => { const api = await this.getApi(); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index fe90ef0..e432796 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -32,6 +32,56 @@ export function validateTransactionSplits(data: unknown): TransactionSplit[] { }); } +export interface BatchTransactionUpdate { + id?: string | null; + import_id?: string | null; + account_id?: string; + date?: string; + amount?: number; + payee_id?: string | null; + payee_name?: string | null; + category_id?: string | null; + memo?: string | null; + cleared?: string; + approved?: boolean; + flag_color?: string | null; +} + +const BATCH_UPDATE_FIELDS: (keyof BatchTransactionUpdate)[] = [ + 'id', 'import_id', 'account_id', 'date', 'amount', + 'payee_id', 'payee_name', 'category_id', 'memo', + 'cleared', 'approved', 'flag_color', +]; + +export function validateBatchUpdates(data: unknown): BatchTransactionUpdate[] { + if (!Array.isArray(data)) { + throw new YnabCliError('Batch updates must be an array', 400); + } + + return data.map((item, index) => { + if (typeof item !== 'object' || item === null) { + throw new YnabCliError(`Update at index ${index} must be an object`, 400); + } + + const update = item as Record; + + if (!update.id && !update.import_id) { + throw new YnabCliError( + `Update at index ${index} must have either "id" or "import_id"`, + 400 + ); + } + + const result: Record = {}; + for (const field of BATCH_UPDATE_FIELDS) { + if (update[field] !== undefined) { + result[field] = update[field]; + } + } + return result as BatchTransactionUpdate; + }); +} + export function validateApiData(data: unknown): Record { if (typeof data !== 'object' || data === null || Array.isArray(data)) { throw new YnabCliError('API data must be an object', 400); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fe54dc5..84dff43 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { client } from '../lib/api-client.js'; import { auth } from '../lib/auth.js'; -import { amountToMilliunits, convertMilliunitsToAmounts } from '../lib/utils.js'; +import { amountToMilliunits, applyFieldSelection, convertMilliunitsToAmounts } from '../lib/utils.js'; const toolRegistry = [ { name: 'list_budgets', description: 'List all budgets in the YNAB account' }, @@ -21,6 +21,7 @@ const toolRegistry = [ { name: 'update_transaction', description: 'Update an existing transaction' }, { name: 'delete_transaction', description: 'Delete a transaction' }, { name: 'import_transactions', description: 'Trigger import of linked bank transactions' }, + { name: 'batch_update_transactions', description: 'Update multiple transactions in a single API call' }, { name: 'list_transactions_by_account', description: 'List transactions for a specific account' }, { name: 'list_transactions_by_category', description: 'List transactions for a specific category' }, { name: 'list_transactions_by_payee', description: 'List transactions for a specific payee' }, @@ -243,6 +244,40 @@ server.tool( async ({ budgetId }) => jsonResponse(await client.importTransactions(budgetId)) ); +server.tool( + 'batch_update_transactions', + 'Update multiple transactions in a single API call. Amounts in dollars.', + { + transactions: z.array(z.object({ + id: z.string().optional().nullable().describe('Transaction ID (required if no import_id)'), + import_id: z.string().optional().nullable().describe('Import ID (required if no id)'), + account_id: z.string().optional().describe('Account ID'), + date: z.string().optional().describe('Transaction date (YYYY-MM-DD)'), + amount: z.number().optional().describe('Amount in dollars (negative for outflow)'), + payee_id: z.string().optional().nullable().describe('Payee ID'), + payee_name: z.string().optional().nullable().describe('Payee name'), + category_id: z.string().optional().nullable().describe('Category ID'), + memo: z.string().optional().nullable().describe('Transaction memo'), + cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional().describe('Cleared status'), + approved: z.boolean().optional().describe('Whether the transaction is approved'), + flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional().nullable().describe('Flag color'), + })).describe('Array of transaction updates'), + budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), + }, + async ({ transactions, budgetId }) => { + const transactionsInMilliunits = transactions.map((update) => ({ + ...update, + ...(update.amount !== undefined ? { amount: amountToMilliunits(update.amount) } : {}), + })); + return currencyResponse( + await client.updateTransactions( + { transactions: transactionsInMilliunits as Parameters[0]['transactions'] }, + budgetId + ) + ); + } +); + server.tool( 'list_transactions_by_account', 'List transactions for a specific account', @@ -250,9 +285,13 @@ server.tool( accountId: z.string().describe('Account ID'), budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), sinceDate: z.string().optional().describe('Only return transactions on or after this date (YYYY-MM-DD)'), + fields: z.string().optional().describe('Comma-separated list of fields to include (e.g., id,date,amount,memo)'), }, - async ({ accountId, budgetId, sinceDate }) => - currencyResponse(await client.getTransactionsByAccount(accountId, { budgetId, sinceDate })) + async ({ accountId, budgetId, sinceDate, fields }) => { + const result = await client.getTransactionsByAccount(accountId, { budgetId, sinceDate }); + if (!fields) return currencyResponse(result); + return currencyResponse(applyFieldSelection(result?.transactions || [], fields)); + } ); server.tool( @@ -262,9 +301,13 @@ server.tool( categoryId: z.string().describe('Category ID'), budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), sinceDate: z.string().optional().describe('Only return transactions on or after this date (YYYY-MM-DD)'), + fields: z.string().optional().describe('Comma-separated list of fields to include (e.g., id,date,amount,memo)'), }, - async ({ categoryId, budgetId, sinceDate }) => - currencyResponse(await client.getTransactionsByCategory(categoryId, { budgetId, sinceDate })) + async ({ categoryId, budgetId, sinceDate, fields }) => { + const result = await client.getTransactionsByCategory(categoryId, { budgetId, sinceDate }); + if (!fields) return currencyResponse(result); + return currencyResponse(applyFieldSelection(result?.transactions || [], fields)); + } ); server.tool( @@ -274,9 +317,13 @@ server.tool( payeeId: z.string().describe('Payee ID'), budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), sinceDate: z.string().optional().describe('Only return transactions on or after this date (YYYY-MM-DD)'), + fields: z.string().optional().describe('Comma-separated list of fields to include (e.g., id,date,amount,memo)'), }, - async ({ payeeId, budgetId, sinceDate }) => - currencyResponse(await client.getTransactionsByPayee(payeeId, { budgetId, sinceDate })) + async ({ payeeId, budgetId, sinceDate, fields }) => { + const result = await client.getTransactionsByPayee(payeeId, { budgetId, sinceDate }); + if (!fields) return currencyResponse(result); + return currencyResponse(applyFieldSelection(result?.transactions || [], fields)); + } ); server.tool(