@slothmoney/agent-cli 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 - 2026-08-07
6
+
7
+ - Create and rename custom categories, with existing icon and category type
8
+ validation and preview-only writes unless `--apply` is supplied.
9
+ - Create and rename personal or joint budget line items while preserving
10
+ historical snapshots and future plan amounts.
11
+ - Filter transactions directly by `--line-item-id`, including paired
12
+ `--category-id` matching for split assignments.
13
+ - Strictly validate category and line-item mutation responses and extend
14
+ command-specific and packed-binary help coverage.
15
+
16
+ ## 0.4.0 - 2026-08-01
17
+
18
+ - Document that new Sloth Money personal access tokens are view-only by
19
+ default, and identify the CLI operations that require explicit write access.
5
20
  - Add the read-only `sloth-agent accounts` command with strict runtime
6
21
  validation for opaque references, ownership, native balances, sources, and
7
22
  freshness metadata.
package/README.md CHANGED
@@ -15,7 +15,7 @@ sloth-agent --version
15
15
  For a one-off pinned run:
16
16
 
17
17
  ```bash
18
- npm exec --yes --package=@slothmoney/agent-cli@0.4.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.5.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -24,6 +24,12 @@ Create a personal access token in Sloth Money under
24
24
  **Settings > Developer access**, then choose the authentication method for
25
25
  where the CLI runs.
26
26
 
27
+ New tokens are view-only. That is enough for `auth status`, `accounts`,
28
+ `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
29
+ creating the token only if the CLI must apply assignments, manage categories
30
+ or line items, ask a partner for an explanation, or manage goals. Token
31
+ permissions cannot be changed later - revoke and reissue the token instead.
32
+
27
33
  ### Local computer
28
34
 
29
35
  On an interactive desktop, save the token in your operating system's secure
@@ -98,6 +104,8 @@ options, output, and examples:
98
104
  sloth-agent auth login --help
99
105
  sloth-agent accounts --help
100
106
  sloth-agent categories --help
107
+ sloth-agent categories create --help
108
+ sloth-agent line-items create --help
101
109
  sloth-agent transactions --help
102
110
  sloth-agent assign --help
103
111
  sloth-agent goals create --help
@@ -158,6 +166,8 @@ does not guarantee that applying it will succeed.
158
166
  sloth-agent assign --input assignments.json --apply
159
167
  ```
160
168
 
169
+ This step requires a token created with **Allow changes**.
170
+
161
171
  Inspect every item in the returned `succeeded` and `failed` arrays.
162
172
 
163
173
  6. Check the result. Successful assignments update the category and optional
@@ -174,6 +184,63 @@ query. Assignments do not create a separate list.
174
184
 
175
185
  ### Other workflows
176
186
 
187
+ Create or rename a custom category. Writes are previews until `--apply` is
188
+ present:
189
+
190
+ ```bash
191
+ sloth-agent categories create \
192
+ --name "Holidays" \
193
+ --icon-key plane \
194
+ --type Wants
195
+
196
+ sloth-agent categories create \
197
+ --name "Holidays" \
198
+ --icon-key plane \
199
+ --type Wants \
200
+ --apply
201
+
202
+ sloth-agent categories rename \
203
+ --category-id category-id \
204
+ --name "Travel fund" \
205
+ --apply
206
+ ```
207
+
208
+ Built-in categories cannot be renamed. A created category is available in the
209
+ next `sloth-agent categories` result without needing a budget allocation.
210
+
211
+ Create or rename a line item within a personal or joint budget:
212
+
213
+ ```bash
214
+ sloth-agent line-items create \
215
+ --scope personal \
216
+ --category-id groceries \
217
+ --name "Weekly shop" \
218
+ --apply
219
+
220
+ sloth-agent line-items rename \
221
+ --scope personal \
222
+ --category-id groceries \
223
+ --line-item-id line-item-id \
224
+ --name "Essentials" \
225
+ --apply
226
+ ```
227
+
228
+ Line-item writes update the current period and explicit future plans.
229
+ Historical snapshots remain unchanged. New items start at zero and do not
230
+ change total allocation.
231
+
232
+ Filter transactions by a line-item ID. Pair it with `--category-id` when the
233
+ same ID may appear under different categories:
234
+
235
+ ```bash
236
+ sloth-agent transactions \
237
+ --assignment-scope personal \
238
+ --category-id groceries \
239
+ --line-item-id line-item-id
240
+ ```
241
+
242
+ The category and line-item IDs must match the same primary assignment or split.
243
+
177
244
  Read the existing Sloth account inventory:
178
245
 
179
246
  ```bash
package/dist/args.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { UsageError } from './errors.js';
2
+ import { CATEGORY_TYPES, ICON_KEYS, } from './category-metadata.js';
2
3
  const PRODUCTION_BASE_URL = 'https://budget.slothmoney.app';
3
4
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
4
5
  function readOptionValue(args, index, name) {
@@ -88,6 +89,28 @@ function parseGoalId(value) {
88
89
  }
89
90
  return goalId;
90
91
  }
92
+ function parseResourceId(value, option) {
93
+ const id = value.trim();
94
+ if (!id
95
+ || id.length > 500
96
+ || id.includes('/')
97
+ || Array.from(id).some((character) => {
98
+ const codePoint = character.codePointAt(0);
99
+ return codePoint !== undefined && (codePoint < 32 || codePoint === 127);
100
+ })) {
101
+ const resource = option === '--category-id' ? 'category' : 'line-item';
102
+ throw new UsageError(`${option} must be a valid ${resource} document ID`);
103
+ }
104
+ return id;
105
+ }
106
+ function parseResourceName(value) {
107
+ const name = value.trim();
108
+ if (!name)
109
+ throw new UsageError('--name requires a value');
110
+ if (name.length > 200)
111
+ throw new UsageError('--name must be at most 200 characters');
112
+ return name;
113
+ }
91
114
  function parseTransactions(args) {
92
115
  const filters = {};
93
116
  for (let index = 0; index < args.length; index += 1) {
@@ -114,6 +137,7 @@ function parseTransactions(args) {
114
137
  '--q',
115
138
  '--account-id',
116
139
  '--category-id',
140
+ '--line-item-id',
117
141
  '--assignment-scope',
118
142
  '--cursor',
119
143
  ]);
@@ -150,6 +174,9 @@ function parseTransactions(args) {
150
174
  else if (name === '--category-id') {
151
175
  filters.categoryId = setOnce(filters.categoryId, value, name);
152
176
  }
177
+ else if (name === '--line-item-id') {
178
+ filters.lineItemId = setOnce(filters.lineItemId, value, name);
179
+ }
153
180
  else if (name === '--assignment-scope') {
154
181
  if (value !== 'personal' && value !== 'joint') {
155
182
  throw new UsageError('--assignment-scope must be personal or joint');
@@ -167,6 +194,105 @@ function parseTransactions(args) {
167
194
  }
168
195
  return filters;
169
196
  }
197
+ function parseNamedOptions(args, commandLabel, allowed) {
198
+ const values = new Map();
199
+ let apply = false;
200
+ for (let index = 0; index < args.length; index += 1) {
201
+ const argument = args[index];
202
+ if (argument === '--apply') {
203
+ if (apply)
204
+ throw new UsageError('--apply may only be provided once');
205
+ apply = true;
206
+ continue;
207
+ }
208
+ const [option, inlineValue] = argument.includes('=')
209
+ ? argument.split(/=(.*)/s, 2)
210
+ : [argument, undefined];
211
+ if (!option || !allowed.has(option)) {
212
+ throw new UsageError(`Unknown ${commandLabel} option: ${argument}`);
213
+ }
214
+ if (values.has(option))
215
+ throw new UsageError(`${option} may only be provided once`);
216
+ const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
217
+ if (inlineValue === undefined)
218
+ index += 1;
219
+ values.set(option, value);
220
+ }
221
+ return { values, apply };
222
+ }
223
+ function requiredOption(values, option, commandLabel) {
224
+ const value = values.get(option);
225
+ if (!value)
226
+ throw new UsageError(`${commandLabel} requires ${option} <value>`);
227
+ return value;
228
+ }
229
+ function parseCategories(args, baseUrl) {
230
+ const subcommand = args.shift();
231
+ if (subcommand === undefined || subcommand === 'list') {
232
+ if (args.length > 0)
233
+ throw new UsageError(`Unknown categories list option: ${args[0]}`);
234
+ return withBaseUrl({ command: 'categories' }, baseUrl);
235
+ }
236
+ if (subcommand === 'create') {
237
+ const { values, apply } = parseNamedOptions(args, 'categories create', new Set(['--name', '--icon-key', '--type']));
238
+ const iconKey = requiredOption(values, '--icon-key', 'categories create');
239
+ const categoryType = requiredOption(values, '--type', 'categories create');
240
+ if (!ICON_KEYS.includes(iconKey)) {
241
+ throw new UsageError(`--icon-key must be one of: ${ICON_KEYS.join(', ')}`);
242
+ }
243
+ if (!CATEGORY_TYPES.includes(categoryType)) {
244
+ throw new UsageError(`--type must be one of: ${CATEGORY_TYPES.join(', ')}`);
245
+ }
246
+ return withBaseUrl({
247
+ command: 'categories-create',
248
+ name: parseResourceName(requiredOption(values, '--name', 'categories create')),
249
+ iconKey: iconKey,
250
+ categoryType: categoryType,
251
+ apply,
252
+ }, baseUrl);
253
+ }
254
+ if (subcommand === 'rename') {
255
+ const { values, apply } = parseNamedOptions(args, 'categories rename', new Set(['--category-id', '--name']));
256
+ return withBaseUrl({
257
+ command: 'categories-rename',
258
+ categoryId: parseResourceId(requiredOption(values, '--category-id', 'categories rename'), '--category-id'),
259
+ name: parseResourceName(requiredOption(values, '--name', 'categories rename')),
260
+ apply,
261
+ }, baseUrl);
262
+ }
263
+ if (subcommand.startsWith('-')) {
264
+ throw new UsageError(`Unknown categories option: ${subcommand}`);
265
+ }
266
+ throw new UsageError(`Unknown categories command: ${subcommand}`);
267
+ }
268
+ function parseLineItems(args, baseUrl) {
269
+ const subcommand = args.shift();
270
+ if (subcommand !== 'create' && subcommand !== 'rename') {
271
+ throw new UsageError('line-items requires create or rename');
272
+ }
273
+ const allowed = new Set(['--scope', '--category-id', '--name']);
274
+ if (subcommand === 'rename')
275
+ allowed.add('--line-item-id');
276
+ const { values, apply } = parseNamedOptions(args, `line-items ${subcommand}`, allowed);
277
+ const scope = requiredOption(values, '--scope', `line-items ${subcommand}`);
278
+ if (scope !== 'personal' && scope !== 'joint') {
279
+ throw new UsageError('--scope must be personal or joint');
280
+ }
281
+ const common = {
282
+ scope: scope,
283
+ categoryId: parseResourceId(requiredOption(values, '--category-id', `line-items ${subcommand}`), '--category-id'),
284
+ name: parseResourceName(requiredOption(values, '--name', `line-items ${subcommand}`)),
285
+ apply,
286
+ };
287
+ if (subcommand === 'create') {
288
+ return withBaseUrl({ command: 'line-items-create', ...common }, baseUrl);
289
+ }
290
+ return withBaseUrl({
291
+ command: 'line-items-rename',
292
+ ...common,
293
+ lineItemId: parseResourceId(requiredOption(values, '--line-item-id', 'line-items rename'), '--line-item-id'),
294
+ }, baseUrl);
295
+ }
170
296
  function withBaseUrl(value, baseUrl) {
171
297
  return baseUrl === undefined ? value : { ...value, baseUrl };
172
298
  }
@@ -411,8 +537,21 @@ function helpTopic(argv) {
411
537
  return 'goals-delete';
412
538
  return 'goals';
413
539
  }
540
+ if (command === 'categories') {
541
+ if (subcommand === 'create')
542
+ return 'categories-create';
543
+ if (subcommand === 'rename')
544
+ return 'categories-rename';
545
+ return 'categories';
546
+ }
547
+ if (command === 'line-items') {
548
+ if (subcommand === 'create')
549
+ return 'line-items-create';
550
+ if (subcommand === 'rename')
551
+ return 'line-items-rename';
552
+ return undefined;
553
+ }
414
554
  if (command === 'accounts'
415
- || command === 'categories'
416
555
  || command === 'transactions'
417
556
  || command === 'assign'
418
557
  || command === 'ask-partner') {
@@ -438,10 +577,10 @@ export function parseArgs(argv) {
438
577
  return parseGoals(args, baseUrl);
439
578
  }
440
579
  if (command === 'categories') {
441
- if (args.length > 0) {
442
- throw new UsageError(`Unknown categories option: ${args[0]}`);
443
- }
444
- return withBaseUrl({ command }, baseUrl);
580
+ return parseCategories(args, baseUrl);
581
+ }
582
+ if (command === 'line-items') {
583
+ return parseLineItems(args, baseUrl);
445
584
  }
446
585
  if (command === 'accounts') {
447
586
  if (args.length > 0) {
@@ -0,0 +1,11 @@
1
+ export const ICON_KEYS = [
2
+ 'home', 'shopping-cart', 'plane', 'car', 'bill', 'dots', 'chart', 'receipt',
3
+ 'credit-card', 'user', 'utensils', 'truck', 'calendar', 'heart', 'shopping-bag',
4
+ 'gift', 'ban', 'question', 'arrows-sync', 'dollar-sign', 'plus',
5
+ ];
6
+ export const CATEGORY_TYPES = [
7
+ 'Needs',
8
+ 'Debts',
9
+ 'Savings & Investments',
10
+ 'Wants',
11
+ ];
package/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import { parseArgs, resolveBaseUrl, } from './args.js';
3
+ import { ICON_KEYS } from './category-metadata.js';
3
4
  import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
4
5
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
5
6
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
6
- export const CLI_VERSION = '0.4.0';
7
+ export const CLI_VERSION = '0.5.0';
7
8
  const REQUEST_TIMEOUT_MS = 60_000;
8
9
  const API_ORIGIN_HELP_LINES = [
9
10
  '',
@@ -25,10 +26,15 @@ export function usageText() {
25
26
  ' sloth-agent auth status [--base-url URL]',
26
27
  ' sloth-agent auth logout [--base-url URL]',
27
28
  ' sloth-agent accounts [--base-url URL]',
28
- ' sloth-agent categories [--base-url URL]',
29
+ ' sloth-agent categories [list] [--base-url URL]',
30
+ ' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
31
+ ' sloth-agent categories rename --category-id ID --name NAME [--apply]',
32
+ ' sloth-agent line-items create --scope personal|joint --category-id ID --name NAME [--apply]',
33
+ ' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply]',
29
34
  ' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
30
35
  ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
31
- ' [--account-id ID] [--category-id ID] [--cursor CURSOR] [--base-url URL]',
36
+ ' [--account-id ID] [--category-id ID] [--line-item-id ID]',
37
+ ' [--cursor CURSOR] [--base-url URL]',
32
38
  ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
33
39
  ' sloth-agent goals [list] [--base-url URL]',
34
40
  ' sloth-agent goals create --name NAME [--target-amount AMOUNT]',
@@ -51,6 +57,10 @@ export function usageText() {
51
57
  'Environment:',
52
58
  ' SLOTH_AGENT_TOKEN Personal access token from Settings > Developer access',
53
59
  ' SLOTH_AGENT_API_BASE_URL Optional API origin; defaults to https://budget.slothmoney.app',
60
+ '',
61
+ 'Access:',
62
+ ' New tokens are view-only. Enable Allow changes in Sloth Money when the',
63
+ ' CLI needs to call write endpoints.',
54
64
  ].join('\n');
55
65
  }
56
66
  export function authHelpText() {
@@ -89,6 +99,8 @@ export function authLoginHelpText() {
89
99
  ' Tokens must start with sloth_pat_v1_ and contain no whitespace.',
90
100
  ' Never pass a token as a command argument.',
91
101
  ' An existing stored credential is replaced only after remote validation succeeds.',
102
+ ' Remote validation requires agent:read. Commands that write also require',
103
+ ' agent:write, selected with Allow changes when the token is created.',
92
104
  ...API_ORIGIN_HELP_LINES,
93
105
  '',
94
106
  'Examples:',
@@ -154,7 +166,7 @@ export function categoriesHelpText() {
154
166
  'Read categories and the personal and joint line items within them.',
155
167
  '',
156
168
  'Usage:',
157
- ' sloth-agent categories [--base-url URL]',
169
+ ' sloth-agent categories [list] [--base-url URL]',
158
170
  '',
159
171
  'Options:',
160
172
  ' --base-url URL Optional. Override the API origin.',
@@ -182,6 +194,85 @@ export function categoriesHelpText() {
182
194
  ' jointLineItemsByCategoryId Joint child line items keyed by category ID',
183
195
  ].join('\n');
184
196
  }
197
+ export function categoriesCreateHelpText() {
198
+ return [
199
+ 'Sloth Agent CLI — categories create',
200
+ '',
201
+ 'Create a custom category.',
202
+ '',
203
+ 'Usage:',
204
+ ' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply] [--base-url URL]',
205
+ '',
206
+ 'Required inputs:',
207
+ ' --name NAME Category name, up to 200 characters.',
208
+ ` --icon-key KEY One of: ${ICON_KEYS.join(', ')}.`,
209
+ ' --type TYPE Needs, Debts, Savings & Investments, or Wants.',
210
+ '',
211
+ 'Write behavior:',
212
+ ' Without --apply, returns a JSON preview and makes no mutation request.',
213
+ ' With --apply, creates the category globally and requires a write-enabled token.',
214
+ ...API_ORIGIN_HELP_LINES,
215
+ '',
216
+ 'Output:',
217
+ ' JSON containing category.id, name, iconKey, categoryType, and source.',
218
+ ].join('\n');
219
+ }
220
+ export function categoriesRenameHelpText() {
221
+ return [
222
+ 'Sloth Agent CLI — categories rename',
223
+ '',
224
+ 'Rename a user-created category. Built-in categories are immutable.',
225
+ '',
226
+ 'Usage:',
227
+ ' sloth-agent categories rename --category-id ID --name NAME [--apply] [--base-url URL]',
228
+ '',
229
+ 'Required inputs:',
230
+ ' --category-id ID Custom category document ID.',
231
+ ' --name NAME New category name, up to 200 characters.',
232
+ '',
233
+ 'Write behavior:',
234
+ ' Without --apply, returns a JSON preview and makes no mutation request.',
235
+ ' With --apply, renames the canonical category and requires a write-enabled token.',
236
+ ...API_ORIGIN_HELP_LINES,
237
+ '',
238
+ 'Output:',
239
+ ' JSON containing the renamed category.',
240
+ ].join('\n');
241
+ }
242
+ function lineItemsMutationHelpText(operation) {
243
+ const rename = operation === 'rename';
244
+ return [
245
+ `Sloth Agent CLI — line-items ${operation}`,
246
+ '',
247
+ rename ? 'Rename a scoped budget line item.' : 'Create a scoped budget line item at zero.',
248
+ '',
249
+ 'Usage:',
250
+ rename
251
+ ? ' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply] [--base-url URL]'
252
+ : ' sloth-agent line-items create --scope personal|joint --category-id ID --name NAME [--apply] [--base-url URL]',
253
+ '',
254
+ 'Required inputs:',
255
+ ' --scope SCOPE personal or joint.',
256
+ ' --category-id ID Parent category ID.',
257
+ ...(rename ? [' --line-item-id ID Existing child line-item ID.'] : []),
258
+ ' --name NAME Line-item name, up to 200 characters.',
259
+ '',
260
+ 'Write behavior:',
261
+ ' Without --apply, returns a JSON preview and makes no mutation request.',
262
+ ' With --apply, updates the current period and explicit future plans.',
263
+ ' Historical snapshots remain unchanged. A write-enabled token is required.',
264
+ ...API_ORIGIN_HELP_LINES,
265
+ '',
266
+ 'Output:',
267
+ ' JSON containing scope, categoryId, and lineItem.id and name.',
268
+ ].join('\n');
269
+ }
270
+ export function lineItemsCreateHelpText() {
271
+ return lineItemsMutationHelpText('create');
272
+ }
273
+ export function lineItemsRenameHelpText() {
274
+ return lineItemsMutationHelpText('rename');
275
+ }
185
276
  export function accountsHelpText() {
186
277
  return [
187
278
  'Sloth Agent CLI — accounts',
@@ -224,6 +315,7 @@ export function transactionsHelpText() {
224
315
  ' --q TEXT Optional. Search transactions by text.',
225
316
  ' --account-id ID Optional. Filter by account ID.',
226
317
  ' --category-id ID Optional. Filter by category ID.',
318
+ ' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
227
319
  ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
228
320
  ' --cursor CURSOR Optional. Continue from a previous nextCursor.',
229
321
  ' --base-url URL Optional. Override the API origin.',
@@ -273,6 +365,7 @@ export function assignHelpText() {
273
365
  ' A successful preview does not guarantee that applying it will succeed.',
274
366
  ' With --apply, assignments are best-effort; any failed item makes the command',
275
367
  ' exit with code 1 while the complete result remains available on stdout.',
368
+ ' Applying requires a write-enabled token created with Allow changes.',
276
369
  '',
277
370
  'Input:',
278
371
  ' The top-level object must contain an assignments array.',
@@ -373,6 +466,7 @@ export function goalsCreateHelpText() {
373
466
  '',
374
467
  'Safety:',
375
468
  ' Without --apply, the command returns a dry-run preview and does not write.',
469
+ ' Applying requires a write-enabled token created with Allow changes.',
376
470
  ' New goals are private to the owner and appended to the existing goal order.',
377
471
  '',
378
472
  'Example:',
@@ -418,6 +512,7 @@ export function goalsUpdateHelpText() {
418
512
  '',
419
513
  'Safety:',
420
514
  ' Without --apply, the command returns a dry-run preview and does not write.',
515
+ ' Applying requires a write-enabled token created with Allow changes.',
421
516
  '',
422
517
  'Output:',
423
518
  ' Preview mode returns dryRun, method, endpoint, and payload.',
@@ -442,6 +537,7 @@ export function goalsDeleteHelpText() {
442
537
  '',
443
538
  'Safety:',
444
539
  ' Without --apply, the command returns a dry-run preview and does not write.',
540
+ ' Applying requires a write-enabled token created with Allow changes.',
445
541
  ' Applying deletion also removes the goal from forecast assignments and',
446
542
  ' removes its goal drift history. This operation cannot be undone.',
447
543
  '',
@@ -467,6 +563,7 @@ export function askPartnerHelpText() {
467
563
  '',
468
564
  'Write behavior:',
469
565
  ' Running this command creates the request immediately. There is no preview mode.',
566
+ ' Creating the request requires a write-enabled token created with Allow changes.',
470
567
  '',
471
568
  'Example:',
472
569
  ' sloth-agent ask-partner --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE',
@@ -485,6 +582,10 @@ export function commandHelpText(topic) {
485
582
  'auth-logout': authLogoutHelpText,
486
583
  accounts: accountsHelpText,
487
584
  categories: categoriesHelpText,
585
+ 'categories-create': categoriesCreateHelpText,
586
+ 'categories-rename': categoriesRenameHelpText,
587
+ 'line-items-create': lineItemsCreateHelpText,
588
+ 'line-items-rename': lineItemsRenameHelpText,
488
589
  transactions: transactionsHelpText,
489
590
  assign: assignHelpText,
490
591
  goals: goalsHelpText,
@@ -582,6 +683,8 @@ function buildTransactionsQuery(filters) {
582
683
  params.set('accountId', filters.accountId);
583
684
  if (filters.categoryId !== undefined)
584
685
  params.set('categoryId', filters.categoryId);
686
+ if (filters.lineItemId !== undefined)
687
+ params.set('lineItemId', filters.lineItemId);
585
688
  if (filters.assignmentScope !== undefined) {
586
689
  params.set('assignmentScope', filters.assignmentScope);
587
690
  }
@@ -733,6 +836,40 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
733
836
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
734
837
  token = credential.token;
735
838
  const headers = requestHeaders(token);
839
+ if (parsed.command === 'categories-create'
840
+ || parsed.command === 'categories-rename'
841
+ || parsed.command === 'line-items-create'
842
+ || parsed.command === 'line-items-rename') {
843
+ const isCategory = parsed.command.startsWith('categories-');
844
+ const isCreate = parsed.command.endsWith('-create');
845
+ const resourceId = parsed.command === 'categories-rename'
846
+ ? parsed.categoryId
847
+ : parsed.command === 'line-items-rename'
848
+ ? parsed.lineItemId
849
+ : null;
850
+ const endpoint = isCreate
851
+ ? `${baseUrl}/api/agent/v1/${isCategory ? 'categories' : 'line-items'}`
852
+ : `${baseUrl}/api/agent/v1/${isCategory ? 'categories' : 'line-items'}/${encodeURIComponent(resourceId)}`;
853
+ const payload = parsed.command === 'categories-create'
854
+ ? { name: parsed.name, iconKey: parsed.iconKey, categoryType: parsed.categoryType }
855
+ : parsed.command === 'categories-rename'
856
+ ? { name: parsed.name }
857
+ : { scope: parsed.scope, categoryId: parsed.categoryId, name: parsed.name };
858
+ const method = isCreate ? 'POST' : 'PATCH';
859
+ if (!parsed.apply) {
860
+ writeJson(writeStdout, { dryRun: true, endpoint, method, payload });
861
+ return 0;
862
+ }
863
+ const response = await fetchImplementation(endpoint, {
864
+ method,
865
+ headers: { ...headers, 'Content-Type': 'application/json' },
866
+ body: JSON.stringify(payload),
867
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
868
+ });
869
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
870
+ writeJson(writeStdout, data);
871
+ return 0;
872
+ }
736
873
  if (parsed.command === 'goals-create') {
737
874
  const endpoint = `${baseUrl}/api/agent/v1/goals`;
738
875
  const payload = {
package/dist/contracts.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ApiError, UsageError, } from './errors.js';
2
+ import { CATEGORY_TYPES, ICON_KEYS } from './category-metadata.js';
2
3
  function isObject(value) {
3
4
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
4
5
  }
@@ -128,6 +129,36 @@ function isCategoryResponse(value) {
128
129
  && isLineItemMap(value.personalLineItemsByCategoryId)
129
130
  && isLineItemMap(value.jointLineItemsByCategoryId));
130
131
  }
132
+ function isCategoryMutationResponse(value) {
133
+ if (!isObject(value) || !hasOnlyFields(value, ['category']) || !isObject(value.category)) {
134
+ return false;
135
+ }
136
+ const category = value.category;
137
+ return (hasOnlyFields(category, ['id', 'name', 'iconKey', 'categoryType', 'source'])
138
+ && typeof category.id === 'string'
139
+ && category.id.trim().length > 0
140
+ && typeof category.name === 'string'
141
+ && category.name.trim().length > 0
142
+ && typeof category.iconKey === 'string'
143
+ && ICON_KEYS.includes(category.iconKey)
144
+ && typeof category.categoryType === 'string'
145
+ && CATEGORY_TYPES.includes(category.categoryType)
146
+ && category.source === 'user');
147
+ }
148
+ function isLineItemMutationResponse(value) {
149
+ if (!isObject(value)
150
+ || !hasOnlyFields(value, ['scope', 'categoryId', 'lineItem'])
151
+ || (value.scope !== 'personal' && value.scope !== 'joint')
152
+ || typeof value.categoryId !== 'string'
153
+ || !value.categoryId.trim()
154
+ || !isObject(value.lineItem))
155
+ return false;
156
+ return (hasOnlyFields(value.lineItem, ['id', 'name'])
157
+ && typeof value.lineItem.id === 'string'
158
+ && value.lineItem.id.trim().length > 0
159
+ && typeof value.lineItem.name === 'string'
160
+ && value.lineItem.name.trim().length > 0);
161
+ }
131
162
  function isTransaction(value) {
132
163
  return (isObject(value)
133
164
  && typeof value.transactionRef === 'string'
@@ -324,17 +355,21 @@ export function parseApiResponse(command, value) {
324
355
  ? isAccountsResponse(value)
325
356
  : command === 'categories'
326
357
  ? isCategoryResponse(value)
327
- : command === 'transactions'
328
- ? isTransactionsResponse(value)
329
- : command === 'assign'
330
- ? isAssignmentResponse(value)
331
- : command === 'ask-partner'
332
- ? isPartnerResponse(value)
333
- : command === 'goals-list'
334
- ? isGoalsResponse(value)
335
- : command === 'goals-delete'
336
- ? isGoalDeleteResponse(value)
337
- : isGoalMutationResponse(value);
358
+ : command === 'categories-create' || command === 'categories-rename'
359
+ ? isCategoryMutationResponse(value)
360
+ : command === 'line-items-create' || command === 'line-items-rename'
361
+ ? isLineItemMutationResponse(value)
362
+ : command === 'transactions'
363
+ ? isTransactionsResponse(value)
364
+ : command === 'assign'
365
+ ? isAssignmentResponse(value)
366
+ : command === 'ask-partner'
367
+ ? isPartnerResponse(value)
368
+ : command === 'goals-list'
369
+ ? isGoalsResponse(value)
370
+ : command === 'goals-delete'
371
+ ? isGoalDeleteResponse(value)
372
+ : isGoalMutationResponse(value);
338
373
  if (!valid) {
339
374
  const label = command === 'assign' ? 'assignment' : command;
340
375
  throw new ApiError(`Invalid ${label} response from the Agent API`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {