@slothmoney/agent-cli 0.10.0 → 0.11.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
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-08-16
4
+
5
+ - Add preview-by-default `budget move` for atomically moving current assigned
6
+ money between categories or To Assign without changing planned budgets.
7
+ - Accept human-readable currency amounts at the CLI boundary, send integer
8
+ pence to the Agent API, and validate the returned affected balances.
9
+
3
10
  ## 0.10.0 - 2026-08-15
4
11
 
5
12
  - Require every goal create to specify a positive target amount and Keep or
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect accounts, investments, and budgets, manage goals, update planned amounts, and categorise transactions through the
3
+ Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, and categorise transactions through the
4
4
  [Sloth Money Agent API](https://slothmoney.app/developers/).
5
5
 
6
6
  ## Install
@@ -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.10.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.11.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -27,7 +27,7 @@ where the CLI runs.
27
27
  New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
28
28
  `budget`, `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
29
29
  creating the token only if the CLI must apply assignments, manage categories
30
- or line items, update planned budgets, manage accounts, ask a partner for an explanation, or manage goals. Token
30
+ or line items, move assigned budget money, update planned budgets, manage accounts, ask a partner for an explanation, or manage goals. Token
31
31
  permissions cannot be changed later - revoke and reissue the token instead.
32
32
 
33
33
  ### Local computer
@@ -105,6 +105,7 @@ sloth-agent auth login --help
105
105
  sloth-agent accounts --help
106
106
  sloth-agent budget --help
107
107
  sloth-agent budget update --help
108
+ sloth-agent budget move --help
108
109
  sloth-agent categories --help
109
110
  sloth-agent categories create --help
110
111
  sloth-agent line-items create --help
@@ -285,6 +286,37 @@ period and everything after it. Earlier and historical periods remain unchanged.
285
286
  Without `--apply`, the CLI validates the file locally and does not load a token
286
287
  or contact Sloth Money. Applying requires a write-enabled token.
287
288
 
289
+ Move current assigned money between two categories, or use the reserved
290
+ `to-assign` ID to move money to or from To Assign:
291
+
292
+ ```bash
293
+ sloth-agent budget move \
294
+ --scope personal \
295
+ --from-category-id activities \
296
+ --to-category-id groceries \
297
+ --amount 52.95
298
+
299
+ sloth-agent budget move \
300
+ --scope personal \
301
+ --from-category-id activities \
302
+ --to-category-id groceries \
303
+ --amount 52.95 \
304
+ --apply
305
+ ```
306
+
307
+ Copy category IDs from `sloth-agent budget` output. `--amount` is expressed in
308
+ the budget currency and accepts up to two decimal places; the CLI converts the
309
+ decimal digits exactly and sends a positive safe-integer number of pence to the
310
+ API. Without `--apply`, the command validates and prints the exact request
311
+ without loading credentials or contacting Sloth Money.
312
+
313
+ Applying subtracts and adds the amount atomically, records the movement in the
314
+ budget history, and returns the affected assigned balances. It does not change
315
+ planned line-item amounts or future budget plans. Like the UI, it permits a
316
+ source category or To Assign to become negative; an automated workflow should
317
+ choose donors from its own available-balance policy. Historical periods cannot
318
+ be changed, and applying requires a write-enabled token.
319
+
288
320
  Create or rename a custom category. Writes are previews until `--apply` is
289
321
  present:
290
322
 
package/dist/args.js CHANGED
@@ -49,16 +49,33 @@ function requireNonEmpty(value, name) {
49
49
  throw new UsageError(`${name} requires a value`);
50
50
  return value;
51
51
  }
52
- function parseGoalAmount(value, name) {
52
+ function validatePositiveDecimalAmount(value, name) {
53
53
  if (!/^\d+(?:\.\d{1,2})?$/.test(value)) {
54
54
  throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
55
55
  }
56
+ const digits = value.replace('.', '');
57
+ if (!/[1-9]/.test(digits)) {
58
+ throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
59
+ }
60
+ }
61
+ function parsePositiveDecimalAmount(value, name) {
62
+ validatePositiveDecimalAmount(value, name);
56
63
  const amount = Number(value);
57
64
  if (!Number.isFinite(amount) || amount <= 0) {
58
65
  throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
59
66
  }
60
67
  return amount;
61
68
  }
69
+ function parsePositiveAmountPence(value, name) {
70
+ validatePositiveDecimalAmount(value, name);
71
+ const [wholePounds, fractionalPounds = ''] = value.split('.');
72
+ const amountPence = BigInt(wholePounds) * 100n
73
+ + BigInt(fractionalPounds.padEnd(2, '0'));
74
+ if (amountPence > BigInt(Number.MAX_SAFE_INTEGER)) {
75
+ throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
76
+ }
77
+ return Number(amountPence);
78
+ }
62
79
  function parseGoalMonthKey(value, name) {
63
80
  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) {
64
81
  throw new UsageError(`${name} must be a valid YYYY-MM month`);
@@ -109,7 +126,7 @@ function parseResourceId(value, option) {
109
126
  const codePoint = character.codePointAt(0);
110
127
  return codePoint !== undefined && (codePoint < 32 || codePoint === 127);
111
128
  })) {
112
- const resource = option === '--category-id' ? 'category' : 'line-item';
129
+ const resource = option === '--line-item-id' ? 'line-item' : 'category';
113
130
  throw new UsageError(`${option} must be a valid ${resource} document ID`);
114
131
  }
115
132
  return id;
@@ -355,13 +372,18 @@ function parseInvestments(args, baseUrl) {
355
372
  return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
356
373
  }
357
374
  function parseBudget(args, baseUrl) {
358
- const update = args[0] === 'update';
359
- if (update)
360
- args.shift();
361
- const { values, apply } = parseNamedOptions(args, update ? 'budget update' : 'budget', new Set(update ? ['--scope', '--period', '--input'] : ['--scope', '--period']));
362
- if (!update && apply)
375
+ const subcommand = args[0] === 'update' || args[0] === 'move' ? args.shift() : undefined;
376
+ const update = subcommand === 'update';
377
+ const move = subcommand === 'move';
378
+ const commandLabel = update ? 'budget update' : move ? 'budget move' : 'budget';
379
+ const { values, apply } = parseNamedOptions(args, commandLabel, new Set(update
380
+ ? ['--scope', '--period', '--input']
381
+ : move
382
+ ? ['--scope', '--period', '--from-category-id', '--to-category-id', '--amount']
383
+ : ['--scope', '--period']));
384
+ if (!update && !move && apply)
363
385
  throw new UsageError('Unknown budget option: --apply');
364
- const scope = requiredOption(values, '--scope', update ? 'budget update' : 'budget');
386
+ const scope = requiredOption(values, '--scope', commandLabel);
365
387
  if (scope !== 'personal' && scope !== 'joint') {
366
388
  throw new UsageError('--scope must be personal or joint');
367
389
  }
@@ -370,8 +392,23 @@ function parseBudget(args, baseUrl) {
370
392
  scope: scope,
371
393
  ...(period === undefined ? {} : { periodKey: parseGoalMonthKey(period, '--period') }),
372
394
  };
373
- if (!update)
395
+ if (!update && !move)
374
396
  return withBaseUrl({ command: 'budget', ...common }, baseUrl);
397
+ if (move) {
398
+ const fromCategoryId = parseResourceId(requiredOption(values, '--from-category-id', commandLabel), '--from-category-id');
399
+ const toCategoryId = parseResourceId(requiredOption(values, '--to-category-id', commandLabel), '--to-category-id');
400
+ if (fromCategoryId === toCategoryId) {
401
+ throw new UsageError('--from-category-id and --to-category-id must differ');
402
+ }
403
+ return withBaseUrl({
404
+ command: 'budget-move',
405
+ ...common,
406
+ fromCategoryId,
407
+ toCategoryId,
408
+ amountPence: parsePositiveAmountPence(requiredOption(values, '--amount', commandLabel), '--amount'),
409
+ apply,
410
+ }, baseUrl);
411
+ }
375
412
  return withBaseUrl({
376
413
  command: 'budget-update',
377
414
  ...common,
@@ -527,7 +564,7 @@ function parseGoals(args, baseUrl) {
527
564
  name = setOnce(name, parseGoalName(value), option);
528
565
  }
529
566
  else if (option === '--target-amount') {
530
- targetAmount = setOnce(targetAmount, parseGoalAmount(value, option), option);
567
+ targetAmount = setOnce(targetAmount, parsePositiveDecimalAmount(value, option), option);
531
568
  }
532
569
  else if (option === '--target-month') {
533
570
  targetMonthKey = setOnce(targetMonthKey, parseGoalMonthKey(value, option), option);
@@ -597,7 +634,7 @@ function parseGoals(args, baseUrl) {
597
634
  name = setOnce(name, parseGoalName(value), option);
598
635
  }
599
636
  else if (option === '--target-amount') {
600
- targetAmount = setOnce(targetAmount, parseGoalAmount(value, option), option);
637
+ targetAmount = setOnce(targetAmount, parsePositiveDecimalAmount(value, option), option);
601
638
  }
602
639
  else if (option === '--target-month') {
603
640
  if (targetMonthKey !== undefined) {
@@ -732,7 +769,11 @@ function helpTopic(argv) {
732
769
  return undefined;
733
770
  }
734
771
  if (command === 'budget') {
735
- return subcommand === 'update' ? 'budget-update' : 'budget';
772
+ if (subcommand === 'update')
773
+ return 'budget-update';
774
+ if (subcommand === 'move')
775
+ return 'budget-move';
776
+ return 'budget';
736
777
  }
737
778
  if (command === 'accounts'
738
779
  || command === 'transactions'
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import { parseArgs, resolveBaseUrl, } from './args.js';
3
3
  import { ICON_KEYS } from './category-metadata.js';
4
- import { parseApiResponse, validateAssignmentPayload, validateBudgetUpdatePayload, } from './contracts.js';
4
+ import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, } from './contracts.js';
5
5
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
6
6
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
7
- export const CLI_VERSION = '0.10.0';
7
+ export const CLI_VERSION = '0.11.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -32,6 +32,8 @@ export function usageText() {
32
32
  ' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
33
33
  ' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
34
34
  ' --input budget.json [--apply] [--base-url URL]',
35
+ ' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
36
+ ' --from-category-id ID --to-category-id ID --amount AMOUNT [--apply]',
35
37
  ' sloth-agent categories [list] [--base-url URL]',
36
38
  ' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
37
39
  ' sloth-agent categories rename --category-id ID --name NAME [--apply]',
@@ -457,6 +459,47 @@ export function budgetUpdateHelpText() {
457
459
  ' Apply mode returns the complete persisted budget response.',
458
460
  ].join('\n');
459
461
  }
462
+ export function budgetMoveHelpText() {
463
+ return [
464
+ 'Sloth Agent CLI — budget move',
465
+ '',
466
+ 'Preview or move assigned money between categories or To Assign.',
467
+ '',
468
+ 'Usage:',
469
+ ' sloth-agent budget move --scope personal|joint [--period YYYY-MM] --from-category-id ID --to-category-id ID --amount AMOUNT [--apply] [--base-url URL]',
470
+ '',
471
+ 'Required inputs:',
472
+ ' --scope personal|joint Budget ownership scope.',
473
+ ' --from-category-id ID Source category ID, or to-assign.',
474
+ ' --to-category-id ID Destination category ID, or to-assign.',
475
+ ' --amount AMOUNT Positive amount in the budget currency, with up to two decimals.',
476
+ ' The integer-pence value must be at most 9,007,199,254,740,991.',
477
+ '',
478
+ 'Optional inputs:',
479
+ ' --period YYYY-MM Defaults to the current Sloth budget period.',
480
+ ' --apply Send the movement. Without it, only validate and preview.',
481
+ ' --base-url URL Override the API origin.',
482
+ ' -h, --help Show this help.',
483
+ ...API_ORIGIN_HELP_LINES,
484
+ '',
485
+ 'Write behavior:',
486
+ ' Without --apply, returns JSON locally without loading credentials or contacting Sloth Money.',
487
+ ' With --apply, atomically subtracts from the source and adds to the destination.',
488
+ ' Use the reserved ID to-assign to move money to or from To Assign.',
489
+ ' The move changes current assigned balances and records budget movement history.',
490
+ ' The source category or To Assign may become negative, so choose the source deliberately.',
491
+ ' It does not change planned amounts or future budget plans.',
492
+ ' Historical periods cannot be changed. Applying requires agent:write.',
493
+ '',
494
+ 'Output:',
495
+ ' Preview mode returns dryRun, endpoint, method, and the amountPence payload.',
496
+ ' Apply mode returns the period, currency, movement, To Assign balance, and affected category balances.',
497
+ '',
498
+ 'Examples:',
499
+ ' sloth-agent budget move --scope personal --from-category-id activities --to-category-id groceries --amount 52.95',
500
+ ' sloth-agent budget move --scope personal --from-category-id activities --to-category-id groceries --amount 52.95 --apply',
501
+ ].join('\n');
502
+ }
460
503
  export function transactionsHelpText() {
461
504
  return [
462
505
  'Sloth Agent CLI — transactions',
@@ -824,6 +867,7 @@ export function commandHelpText(topic) {
824
867
  'accounts-remove': accountsRemoveHelpText,
825
868
  investments: investmentsHelpText,
826
869
  budget: budgetHelpText,
870
+ 'budget-move': budgetMoveHelpText,
827
871
  'budget-update': budgetUpdateHelpText,
828
872
  categories: categoriesHelpText,
829
873
  'categories-create': categoriesCreateHelpText,
@@ -1140,6 +1184,24 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1140
1184
  });
1141
1185
  return 0;
1142
1186
  }
1187
+ const budgetMovementPayload = parsed.command === 'budget-move'
1188
+ ? {
1189
+ scope: parsed.scope,
1190
+ ...(parsed.periodKey === undefined ? {} : { periodKey: parsed.periodKey }),
1191
+ fromCategoryId: parsed.fromCategoryId,
1192
+ toCategoryId: parsed.toCategoryId,
1193
+ amountPence: parsed.amountPence,
1194
+ }
1195
+ : undefined;
1196
+ if (parsed.command === 'budget-move' && !parsed.apply) {
1197
+ writeJson(writeStdout, {
1198
+ dryRun: true,
1199
+ endpoint: `${baseUrl}/api/agent/v1/budget-movements`,
1200
+ method: 'POST',
1201
+ payload: budgetMovementPayload,
1202
+ });
1203
+ return 0;
1204
+ }
1143
1205
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
1144
1206
  token = credential.token;
1145
1207
  const headers = requestHeaders(token);
@@ -1182,6 +1244,17 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1182
1244
  writeJson(writeStdout, data);
1183
1245
  return 0;
1184
1246
  }
1247
+ if (parsed.command === 'budget-move') {
1248
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budget-movements`, {
1249
+ method: 'POST',
1250
+ headers: { ...headers, 'Content-Type': 'application/json' },
1251
+ body: JSON.stringify(budgetMovementPayload),
1252
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1253
+ });
1254
+ const data = validateBudgetMovementResponse(await parseHttpResponse(response, token), budgetMovementPayload);
1255
+ writeJson(writeStdout, data);
1256
+ return 0;
1257
+ }
1185
1258
  if (parsed.command === 'categories-create'
1186
1259
  || parsed.command === 'categories-rename'
1187
1260
  || parsed.command === 'line-items-create'
package/dist/contracts.js CHANGED
@@ -375,6 +375,63 @@ function isBudgetResponse(value) {
375
375
  && Array.isArray(value.categories)
376
376
  && value.categories.every(isBudgetCategory));
377
377
  }
378
+ function isBudgetMovementResponse(value) {
379
+ return (isObject(value)
380
+ && hasOnlyFields(value, [
381
+ 'moved',
382
+ 'scope',
383
+ 'periodKey',
384
+ 'currency',
385
+ 'fromCategoryId',
386
+ 'toCategoryId',
387
+ 'amountPence',
388
+ 'toAssignPence',
389
+ 'categoryBalances',
390
+ ])
391
+ && value.moved === true
392
+ && (value.scope === 'personal' || value.scope === 'joint')
393
+ && typeof value.periodKey === 'string'
394
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
395
+ && isCurrency(value.currency)
396
+ && typeof value.fromCategoryId === 'string'
397
+ && value.fromCategoryId.trim().length > 0
398
+ && typeof value.toCategoryId === 'string'
399
+ && value.toCategoryId.trim().length > 0
400
+ && value.fromCategoryId !== value.toCategoryId
401
+ && isNonnegativeSafeInteger(value.amountPence)
402
+ && value.amountPence > 0
403
+ && isSafeInteger(value.toAssignPence)
404
+ && Array.isArray(value.categoryBalances)
405
+ && value.categoryBalances.length >= 1
406
+ && value.categoryBalances.length <= 2
407
+ && value.categoryBalances.every((balance) => (isObject(balance)
408
+ && hasOnlyFields(balance, ['categoryId', 'assignedPence'])
409
+ && typeof balance.categoryId === 'string'
410
+ && balance.categoryId.trim().length > 0
411
+ && isSafeInteger(balance.assignedPence))));
412
+ }
413
+ export function validateBudgetMovementResponse(value, expected) {
414
+ if (!isBudgetMovementResponse(value)) {
415
+ throw new ApiError('Invalid budget-move response from the Agent API');
416
+ }
417
+ const expectedCategoryIds = [expected.fromCategoryId, expected.toCategoryId]
418
+ .filter(categoryId => categoryId !== 'to-assign')
419
+ .sort();
420
+ const actualCategoryIds = value.categoryBalances
421
+ .map(balance => balance.categoryId)
422
+ .sort();
423
+ const matchesRequest = value.scope === expected.scope
424
+ && (expected.periodKey === undefined || value.periodKey === expected.periodKey)
425
+ && value.fromCategoryId === expected.fromCategoryId
426
+ && value.toCategoryId === expected.toCategoryId
427
+ && value.amountPence === expected.amountPence;
428
+ const matchesAffectedCategories = actualCategoryIds.length === expectedCategoryIds.length
429
+ && actualCategoryIds.every((categoryId, index) => categoryId === expectedCategoryIds[index]);
430
+ if (!matchesRequest || !matchesAffectedCategories) {
431
+ throw new ApiError('Invalid budget-move response from the Agent API');
432
+ }
433
+ return value;
434
+ }
378
435
  function isNullableNonEmptyString(value) {
379
436
  return value === null || (typeof value === 'string'
380
437
  && value.length > 0
@@ -511,23 +568,25 @@ export function parseApiResponse(command, value) {
511
568
  ? isInvestmentsResponse(value)
512
569
  : command === 'budget' || command === 'budget-update'
513
570
  ? isBudgetResponse(value)
514
- : command === 'categories'
515
- ? isCategoryResponse(value)
516
- : command === 'categories-create' || command === 'categories-rename'
517
- ? isCategoryMutationResponse(value)
518
- : command === 'line-items-create' || command === 'line-items-rename'
519
- ? isLineItemMutationResponse(value)
520
- : command === 'transactions'
521
- ? isTransactionsResponse(value)
522
- : command === 'assign'
523
- ? isAssignmentResponse(value)
524
- : command === 'ask-partner'
525
- ? isPartnerResponse(value)
526
- : command === 'goals-list'
527
- ? isGoalsResponse(value)
528
- : command === 'goals-delete'
529
- ? isGoalDeleteResponse(value)
530
- : isGoalMutationResponse(value);
571
+ : command === 'budget-move'
572
+ ? isBudgetMovementResponse(value)
573
+ : command === 'categories'
574
+ ? isCategoryResponse(value)
575
+ : command === 'categories-create' || command === 'categories-rename'
576
+ ? isCategoryMutationResponse(value)
577
+ : command === 'line-items-create' || command === 'line-items-rename'
578
+ ? isLineItemMutationResponse(value)
579
+ : command === 'transactions'
580
+ ? isTransactionsResponse(value)
581
+ : command === 'assign'
582
+ ? isAssignmentResponse(value)
583
+ : command === 'ask-partner'
584
+ ? isPartnerResponse(value)
585
+ : command === 'goals-list'
586
+ ? isGoalsResponse(value)
587
+ : command === 'goals-delete'
588
+ ? isGoalDeleteResponse(value)
589
+ : isGoalMutationResponse(value);
531
590
  if (!valid) {
532
591
  const label = command === 'assign' ? 'assignment' : command;
533
592
  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.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {