@slothmoney/agent-cli 0.11.0 → 0.12.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 - 2026-08-16
4
+
5
+ - Add read-only `budget status` for current-period assigned, spent, and
6
+ available category amounts, including refresh and unallocated-activity
7
+ signals for safer automated budget review.
8
+
3
9
  ## 0.11.0 - 2026-08-16
4
10
 
5
11
  - Add preview-by-default `budget move` for atomically moving current assigned
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.11.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.12.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -104,6 +104,7 @@ options, output, and examples:
104
104
  sloth-agent auth login --help
105
105
  sloth-agent accounts --help
106
106
  sloth-agent budget --help
107
+ sloth-agent budget status --help
107
108
  sloth-agent budget update --help
108
109
  sloth-agent budget move --help
109
110
  sloth-agent categories --help
@@ -249,6 +250,21 @@ The result includes the budget period and status, currency, the effective plan,
249
250
  stored funding amounts when available, categories, line items, and planned
250
251
  amounts in pence.
251
252
 
253
+ Read current assigned, spent, and available money without aggregating
254
+ transactions yourself:
255
+
256
+ ```bash
257
+ sloth-agent budget status --scope personal
258
+ ```
259
+
260
+ The server applies its normal once-per-UTC-day automatic transaction refresh
261
+ policy before returning the current Sloth period dates and signed booked
262
+ activity. For each category, `availablePence` is
263
+ `assignedPence - spentPence`; a negative value is over budget, and refunds
264
+ reduce `spentPence`. Check `refresh`,
265
+ `activity.uncategorizedSpentPence`, and `activity.unmappedSpentPence` before
266
+ using the result to suggest a reallocation. This command is read-only.
267
+
252
268
  Update selected line-item amounts by creating `budget.json`:
253
269
 
254
270
  ```json
package/dist/args.js CHANGED
@@ -372,21 +372,38 @@ function parseInvestments(args, baseUrl) {
372
372
  return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
373
373
  }
374
374
  function parseBudget(args, baseUrl) {
375
- const subcommand = args[0] === 'update' || args[0] === 'move' ? args.shift() : undefined;
375
+ const subcommand = args[0] === 'status' || args[0] === 'update' || args[0] === 'move'
376
+ ? args.shift()
377
+ : undefined;
378
+ const status = subcommand === 'status';
376
379
  const update = subcommand === 'update';
377
380
  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']));
381
+ const commandLabel = status
382
+ ? 'budget status'
383
+ : update
384
+ ? 'budget update'
385
+ : move
386
+ ? 'budget move'
387
+ : 'budget';
388
+ const { values, apply } = parseNamedOptions(args, commandLabel, new Set(status
389
+ ? ['--scope']
390
+ : update
391
+ ? ['--scope', '--period', '--input']
392
+ : move
393
+ ? ['--scope', '--period', '--from-category-id', '--to-category-id', '--amount']
394
+ : ['--scope', '--period']));
384
395
  if (!update && !move && apply)
385
- throw new UsageError('Unknown budget option: --apply');
396
+ throw new UsageError(`Unknown ${commandLabel} option: --apply`);
386
397
  const scope = requiredOption(values, '--scope', commandLabel);
387
398
  if (scope !== 'personal' && scope !== 'joint') {
388
399
  throw new UsageError('--scope must be personal or joint');
389
400
  }
401
+ if (status) {
402
+ return withBaseUrl({
403
+ command: 'budget-status',
404
+ scope: scope,
405
+ }, baseUrl);
406
+ }
390
407
  const period = values.get('--period');
391
408
  const common = {
392
409
  scope: scope,
@@ -769,6 +786,8 @@ function helpTopic(argv) {
769
786
  return undefined;
770
787
  }
771
788
  if (command === 'budget') {
789
+ if (subcommand === 'status')
790
+ return 'budget-status';
772
791
  if (subcommand === 'update')
773
792
  return 'budget-update';
774
793
  if (subcommand === 'move')
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { ICON_KEYS } from './category-metadata.js';
4
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.11.0';
7
+ export const CLI_VERSION = '0.12.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -30,6 +30,7 @@ export function usageText() {
30
30
  ' sloth-agent accounts remove --account-ref REF [--apply]',
31
31
  ' sloth-agent investments [--account-ref REF] [--base-url URL]',
32
32
  ' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
33
+ ' sloth-agent budget status --scope personal|joint [--base-url URL]',
33
34
  ' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
34
35
  ' --input budget.json [--apply] [--base-url URL]',
35
36
  ' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
@@ -421,6 +422,35 @@ export function budgetHelpText() {
421
422
  ' Categories also include plannedPence and assignedPence.',
422
423
  ].join('\n');
423
424
  }
425
+ export function budgetStatusHelpText() {
426
+ return [
427
+ 'Sloth Agent CLI — budget status',
428
+ '',
429
+ 'Read assigned, spent, and available money for the current Sloth budget period.',
430
+ '',
431
+ 'Usage:',
432
+ ' sloth-agent budget status --scope personal|joint [--base-url URL]',
433
+ '',
434
+ 'Options:',
435
+ ' --scope personal|joint Required. Budget ownership scope.',
436
+ ' --base-url URL Optional. Override the API origin.',
437
+ ' -h, --help Show this help.',
438
+ ...API_ORIGIN_HELP_LINES,
439
+ '',
440
+ 'Access and freshness:',
441
+ ' This command is read-only, requires agent:read, and never changes the budget.',
442
+ ' The server applies its normal once-per-UTC-day automatic transaction refresh policy.',
443
+ ' Inspect refresh.status and refresh.reason before relying on the result.',
444
+ '',
445
+ 'Output:',
446
+ ' categories[].assignedPence is the money assigned to the category.',
447
+ ' categories[].spentPence is signed booked activity; refunds reduce it.',
448
+ ' categories[].availablePence equals assignedPence minus spentPence.',
449
+ ' Negative availablePence means the category is over budget.',
450
+ ' activity contains the period dates, transaction count, uncategorizedSpentPence,',
451
+ ' and unmappedSpentPence. Review either nonzero value before moving money.',
452
+ ].join('\n');
453
+ }
424
454
  export function budgetUpdateHelpText() {
425
455
  return [
426
456
  'Sloth Agent CLI — budget update',
@@ -867,6 +897,7 @@ export function commandHelpText(topic) {
867
897
  'accounts-remove': accountsRemoveHelpText,
868
898
  investments: investmentsHelpText,
869
899
  budget: budgetHelpText,
900
+ 'budget-status': budgetStatusHelpText,
870
901
  'budget-move': budgetMoveHelpText,
871
902
  'budget-update': budgetUpdateHelpText,
872
903
  categories: categoriesHelpText,
@@ -1429,16 +1460,19 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1429
1460
  writeJson(writeStdout, withListedGoalPriorities(data));
1430
1461
  return 0;
1431
1462
  }
1432
- if (parsed.command === 'budget') {
1463
+ if (parsed.command === 'budget' || parsed.command === 'budget-status') {
1433
1464
  const query = new URLSearchParams({ scope: parsed.scope });
1434
- if (parsed.periodKey !== undefined)
1465
+ if (parsed.command === 'budget' && parsed.periodKey !== undefined) {
1435
1466
  query.set('periodKey', parsed.periodKey);
1436
- const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budgets?${query.toString()}`, {
1467
+ }
1468
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/${parsed.command === 'budget' ? 'budgets' : 'budget-status'}?${query.toString()}`, {
1437
1469
  method: 'GET',
1438
- headers,
1470
+ headers: parsed.command === 'budget-status'
1471
+ ? { ...headers, Prefer: 'wait=45' }
1472
+ : headers,
1439
1473
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1440
1474
  });
1441
- const data = parseApiResponse('budget', await parseHttpResponse(response, token));
1475
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1442
1476
  writeJson(writeStdout, data);
1443
1477
  return 0;
1444
1478
  }
package/dist/contracts.js CHANGED
@@ -227,28 +227,32 @@ function isTransaction(value) {
227
227
  || value.incomeSubtype === 'pay'
228
228
  || value.incomeSubtype === 'interest'));
229
229
  }
230
+ const REFRESH_STATUSES = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
231
+ const REFRESH_REASONS = new Set([
232
+ 'all_fetched_today',
233
+ 'no_api_connections',
234
+ 'no_selected_accounts',
235
+ 'refreshed',
236
+ 'wait_timeout',
237
+ 'account_failures',
238
+ 'partial_already_attempted',
239
+ 'refresh_error',
240
+ ]);
241
+ function isRefreshStatus(value) {
242
+ return (isObject(value)
243
+ && hasOnlyFields(value, ['status', 'reason', 'utcDate'])
244
+ && typeof value.status === 'string'
245
+ && REFRESH_STATUSES.has(value.status)
246
+ && typeof value.reason === 'string'
247
+ && REFRESH_REASONS.has(value.reason)
248
+ && isIsoDate(value.utcDate));
249
+ }
230
250
  function isTransactionsResponse(value) {
231
- const validStatuses = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
232
- const validReasons = new Set([
233
- 'all_fetched_today',
234
- 'no_api_connections',
235
- 'no_selected_accounts',
236
- 'refreshed',
237
- 'wait_timeout',
238
- 'account_failures',
239
- 'refresh_error',
240
- ]);
241
- const refresh = isObject(value) ? value.refresh : undefined;
242
251
  return (isObject(value)
243
252
  && Array.isArray(value.transactions)
244
253
  && value.transactions.every(isTransaction)
245
254
  && (value.nextCursor === null || typeof value.nextCursor === 'string')
246
- && isObject(refresh)
247
- && typeof refresh.status === 'string'
248
- && validStatuses.has(refresh.status)
249
- && typeof refresh.reason === 'string'
250
- && validReasons.has(refresh.reason)
251
- && isIsoDate(refresh.utcDate));
255
+ && isRefreshStatus(value.refresh));
252
256
  }
253
257
  function isAssignmentResponse(value) {
254
258
  return (isObject(value)
@@ -375,6 +379,68 @@ function isBudgetResponse(value) {
375
379
  && Array.isArray(value.categories)
376
380
  && value.categories.every(isBudgetCategory));
377
381
  }
382
+ function isBudgetStatusResponse(value) {
383
+ if (!isObject(value)
384
+ || !hasOnlyFields(value, [
385
+ 'scope',
386
+ 'periodKey',
387
+ 'periodStatus',
388
+ 'currency',
389
+ 'effectiveFromPeriodKey',
390
+ 'funding',
391
+ 'activity',
392
+ 'refresh',
393
+ 'categories',
394
+ ]))
395
+ return false;
396
+ return ((value.scope === 'personal' || value.scope === 'joint')
397
+ && typeof value.periodKey === 'string'
398
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
399
+ && value.periodStatus === 'current'
400
+ && isCurrency(value.currency)
401
+ && typeof value.effectiveFromPeriodKey === 'string'
402
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
403
+ && (value.funding === null
404
+ || (isObject(value.funding)
405
+ && hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
406
+ && isSafeInteger(value.funding.toAssignPence)
407
+ && isSafeInteger(value.funding.nextPeriodReservePence)))
408
+ && isObject(value.activity)
409
+ && hasOnlyFields(value.activity, [
410
+ 'startDate',
411
+ 'endDate',
412
+ 'transactionCount',
413
+ 'uncategorizedSpentPence',
414
+ 'unmappedSpentPence',
415
+ ])
416
+ && isIsoDate(value.activity.startDate)
417
+ && isIsoDate(value.activity.endDate)
418
+ && value.activity.startDate <= value.activity.endDate
419
+ && isNonnegativeSafeInteger(value.activity.transactionCount)
420
+ && isSafeInteger(value.activity.uncategorizedSpentPence)
421
+ && isSafeInteger(value.activity.unmappedSpentPence)
422
+ && isRefreshStatus(value.refresh)
423
+ && Array.isArray(value.categories)
424
+ && value.categories.every((category) => (isObject(category)
425
+ && hasOnlyFields(category, [
426
+ 'id',
427
+ 'name',
428
+ 'plannedPence',
429
+ 'assignedPence',
430
+ 'spentPence',
431
+ 'availablePence',
432
+ ])
433
+ && typeof category.id === 'string'
434
+ && category.id.trim().length > 0
435
+ && typeof category.name === 'string'
436
+ && category.name.trim().length > 0
437
+ && isNonnegativeSafeInteger(category.plannedPence)
438
+ && isSafeInteger(category.assignedPence)
439
+ && isSafeInteger(category.spentPence)
440
+ && isSafeInteger(category.availablePence)
441
+ && Number.isSafeInteger(category.assignedPence - category.spentPence)
442
+ && category.availablePence === category.assignedPence - category.spentPence)));
443
+ }
378
444
  function isBudgetMovementResponse(value) {
379
445
  return (isObject(value)
380
446
  && hasOnlyFields(value, [
@@ -568,25 +634,27 @@ export function parseApiResponse(command, value) {
568
634
  ? isInvestmentsResponse(value)
569
635
  : command === 'budget' || command === 'budget-update'
570
636
  ? isBudgetResponse(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);
637
+ : command === 'budget-status'
638
+ ? isBudgetStatusResponse(value)
639
+ : command === 'budget-move'
640
+ ? isBudgetMovementResponse(value)
641
+ : command === 'categories'
642
+ ? isCategoryResponse(value)
643
+ : command === 'categories-create' || command === 'categories-rename'
644
+ ? isCategoryMutationResponse(value)
645
+ : command === 'line-items-create' || command === 'line-items-rename'
646
+ ? isLineItemMutationResponse(value)
647
+ : command === 'transactions'
648
+ ? isTransactionsResponse(value)
649
+ : command === 'assign'
650
+ ? isAssignmentResponse(value)
651
+ : command === 'ask-partner'
652
+ ? isPartnerResponse(value)
653
+ : command === 'goals-list'
654
+ ? isGoalsResponse(value)
655
+ : command === 'goals-delete'
656
+ ? isGoalDeleteResponse(value)
657
+ : isGoalMutationResponse(value);
590
658
  if (!valid) {
591
659
  const label = command === 'assign' ? 'assignment' : command;
592
660
  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.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {