diff --git a/README.md b/README.md index b8f0bad..c703552 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ ynab accounts transactions ```bash ynab categories list ynab categories view +ynab categories update [--name ] [--note ] [--category-group-id ] [--goal-target ] ynab categories budget --month --amount ynab categories transactions ``` diff --git a/src/commands/categories.ts b/src/commands/categories.ts index f8ba596..cdc9c72 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -36,6 +36,64 @@ export function createCategoriesCommand(): Command { }) ); + cmd + .command('update') + .description('Update category details') + .argument('', 'Category ID') + .option('--name ', 'New category name') + .option('--note ', 'Category note (use empty string to clear)') + .option('--category-group-id ', 'Move to a different category group') + .option('--goal-target ', 'Goal target amount in dollars (ignored if category has no goal)', parseFloat) + .option('-b, --budget ', 'Budget ID') + .action( + withErrorHandling( + async ( + id: string, + options: { + name?: string; + note?: string; + categoryGroupId?: string; + goalTarget?: number; + budget?: string; + } & CommandOptions + ) => { + if (options.name === undefined && options.note === undefined && options.categoryGroupId === undefined && options.goalTarget === undefined) { + throw new YnabCliError( + 'At least one field to update must be provided (--name, --note, --category-group-id, or --goal-target)', + 400 + ); + } + + if (options.name !== undefined && options.name.trim() === '') { + throw new YnabCliError('Category name cannot be empty or whitespace', 400); + } + + const updateData: { + name?: string; + note?: string | null; + category_group_id?: string; + goal_target?: number | null; + } = {}; + + if (options.name !== undefined) { + updateData.name = options.name.trim(); + } + if (options.note !== undefined) { + updateData.note = options.note.trim() || null; + } + if (options.categoryGroupId !== undefined) { + updateData.category_group_id = options.categoryGroupId; + } + if (options.goalTarget !== undefined) { + updateData.goal_target = amountToMilliunits(options.goalTarget); + } + + const category = await client.updateCategory(id, { category: updateData }, options.budget); + outputJson(category); + } + ) + ); + cmd .command('budget') .description('Set category budgeted amount for a month (overrides existing amount)') diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e95ff12..f6bf6f8 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -159,6 +159,15 @@ export class YnabClient { }); } + async updateCategory(categoryId: string, data: ynab.PatchCategoryWrapper, budgetId?: string) { + return this.withErrorHandling(async () => { + const api = await this.getApi(); + const id = await this.getBudgetId(budgetId); + const response = await api.categories.updateCategory(id, categoryId, data); + return response.data.category; + }); + } + async getPayees(budgetId?: string, lastKnowledgeOfServer?: number) { return this.withErrorHandling(async () => { const api = await this.getApi(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 451745c..5616c0f 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 { convertMilliunitsToAmounts } from '../lib/utils.js'; +import { amountToMilliunits, convertMilliunitsToAmounts } from '../lib/utils.js'; const toolRegistry = [ { name: 'list_budgets', description: 'List all budgets in the YNAB account' }, @@ -12,6 +12,7 @@ const toolRegistry = [ { name: 'get_account', description: 'Get detailed information about a specific account' }, { name: 'list_categories', description: 'List all category groups and categories in a budget' }, { name: 'get_category', description: 'Get detailed information about a specific category' }, + { name: 'update_category', description: 'Update category name, note, group, or goal target' }, { name: 'list_transactions', description: 'List transactions with optional filtering' }, { name: 'get_transaction', description: 'Get detailed information about a specific transaction' }, { name: 'list_transactions_by_account', description: 'List transactions for a specific account' }, @@ -84,6 +85,27 @@ server.tool( async ({ categoryId, budgetId }) => currencyResponse(await client.getCategory(categoryId, budgetId)) ); +server.tool( + 'update_category', + 'Update category name, note, group, or goal target', + { + categoryId: z.string().describe('Category ID'), + name: z.string().optional().describe('New category name'), + note: z.string().optional().describe('Category note (use empty string to clear)'), + categoryGroupId: z.string().optional().describe('Move to a different category group'), + goalTarget: z.number().optional().describe('Goal target amount in dollars (ignored if category has no goal)'), + budgetId: z.string().optional().describe('Budget ID (uses default if not specified)'), + }, + async ({ categoryId, name, note, categoryGroupId, goalTarget, budgetId }) => { + const updateData: Record = {}; + if (name !== undefined) updateData.name = name.trim(); + if (note !== undefined) updateData.note = note.trim() || null; + if (categoryGroupId !== undefined) updateData.category_group_id = categoryGroupId; + if (goalTarget !== undefined) updateData.goal_target = amountToMilliunits(goalTarget); + return currencyResponse(await client.updateCategory(categoryId, { category: updateData }, budgetId)); + } +); + server.tool( 'list_transactions', 'List transactions with optional filtering',