@slothmoney/agent-cli 0.16.0 → 0.18.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 - 2026-08-25
4
+
5
+ - Add optional `--period YYYY-MM` to `budget status` for current and historical
6
+ Sloth budget periods.
7
+ - Replace signed spending-only status with clear money-in, money-out, and net
8
+ activity for Income, Transfer, None, ordinary, custom, and uncategorized
9
+ transactions.
10
+ - Accept the old current-status response temporarily during the coordinated
11
+ backend rollout; historical responses use the new strict contract.
12
+ - Add a release preflight that compares local, packed, globally installed, and
13
+ npm-registry versions and checks every nested help route.
14
+
15
+ ## 0.17.0 - 2026-08-25
16
+
17
+ - Run applied transaction-assignment batches through resumable server
18
+ operations while keeping the existing `assign --input ... --apply` command
19
+ and terminal `succeeded`/`failed` JSON output unchanged.
20
+ - Retry transient submission and status requests with a deterministic
21
+ request key, so re-running an interrupted command resumes the same operation.
22
+ - Strictly validate operation progress, expiry, counts, and ordered terminal
23
+ item receipts before printing an assignment result.
24
+
3
25
  ## 0.16.0 - 2026-08-21
4
26
 
5
27
  - Add receipt image extraction plus read, preview-by-default attach, and remove
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.16.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.17.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -302,6 +302,10 @@ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
302
302
 
303
303
  These are placeholders. Do not submit the example values.
304
304
 
305
+ Each `transactionRef` may appear only once in an assignment file. Split one
306
+ transaction across categories with `categorySplits` instead of adding the same
307
+ transaction twice.
308
+
305
309
  4. Preview the assignment without writing:
306
310
 
307
311
  ```bash
@@ -321,7 +325,17 @@ sloth-agent assign --input assignments.json --apply
321
325
 
322
326
  This step requires a token created with **Allow changes**.
323
327
 
324
- Inspect every item in the returned `succeeded` and `failed` arrays.
328
+ The CLI submits a durable server operation and polls its authenticated status
329
+ until every item has finished. It then prints the same `succeeded` and `failed`
330
+ arrays as before, so existing agent workflows do not need to change. Inspect
331
+ every item in both arrays.
332
+
333
+ If the command is interrupted or a request times out, re-run the same command
334
+ with the same assignment input. The CLI derives the same request key
335
+ from the validated assignments, so the server resumes the existing operation
336
+ instead of applying the batch again. The server retains operation status and
337
+ item receipts for seven days. Changing the assignments creates a different
338
+ operation.
325
339
 
326
340
  6. Check the result in the same assignment scope that you changed. Successful
327
341
  assignments update the category and optional budget line item on the
@@ -414,20 +428,27 @@ The result includes the budget period and status, currency, the effective plan,
414
428
  stored funding amounts when available, categories, line items, and planned
415
429
  amounts in pence.
416
430
 
417
- Read current assigned, spent, and available money without aggregating
418
- transactions yourself:
431
+ Read booked activity for the current or a historical Sloth period:
419
432
 
420
433
  ```bash
421
434
  sloth-agent budget status --scope personal
435
+ sloth-agent budget status --scope personal --period 2026-07
422
436
  ```
423
437
 
424
- The server applies its normal once-per-UTC-day automatic transaction refresh
425
- policy before returning the current Sloth period dates and signed booked
426
- activity. For each category, `availablePence` is
427
- `assignedPence - spentPence`; a negative value is over budget, and refunds
428
- reduce `spentPence`. Check `refresh`,
429
- `activity.uncategorizedSpentPence`, and `activity.unmappedSpentPence` before
430
- using the result to suggest a reallocation. This command is read-only.
438
+ The period key names the calendar month containing that Sloth period's start
439
+ boundary. Its end date can fall in the following month.
440
+
441
+ The current period uses Sloth's normal once-per-UTC-day transaction refresh;
442
+ historical periods are cache-only and return `refresh: null`. Activity contains
443
+ nonnegative `moneyInPence` and `moneyOutPence` plus their difference as
444
+ `netPence`. Income, Transfer, explicit None, budget categories, and observed
445
+ custom categories are normal rows. A transaction with no category at all is
446
+ reported separately under `activity.uncategorized`.
447
+
448
+ `budget` contains assigned, spent, and available category amounts when a
449
+ trustworthy period plan exists. It is `null` when it does not; activity still
450
+ returns. The response includes only the period budget currency and silently
451
+ ignores rows in other currencies. This command is read-only.
431
452
 
432
453
  Update selected line-item amounts by creating `budget.json`:
433
454
 
@@ -624,9 +645,12 @@ sloth-agent investments --account-ref sloth_account_v1_...
624
645
  Investment reads are cache-only and do not refresh a brokerage. Holding
625
646
  quantities, unit prices, market values, currencies, and freshness are returned
626
647
  in provider-native terms. They are not converted or guaranteed to reconcile
627
- to an account total reported in another currency. Caller-owned personal and
628
- joint linked investment accounts are included; partner-owned accounts, manual
629
- holdings, and investment activities are not.
648
+ to an account total reported in another currency. An investment account total
649
+ and its nested holdings describe the same portfolio, so do not add them
650
+ together. Do not add values in different currencies without an explicit
651
+ conversion. Caller-owned personal and joint linked investment accounts are
652
+ included; partner-owned accounts, manual holdings, and investment activities
653
+ are not.
630
654
 
631
655
  List your goals:
632
656
 
@@ -765,19 +789,26 @@ Command results are JSON on stdout. Diagnostics are written to stderr.
765
789
  | `2` | Invalid command, option, URL, date, auth input, goal input, or assignment input |
766
790
  | `3` | No credential or native secure storage is unavailable |
767
791
 
768
- Assignment writes are best-effort. A response containing any failed assignment
769
- returns exit code `1` while preserving the complete API response on stdout.
792
+ Assignment writes run as durable, best-effort operations. The CLI waits for the
793
+ terminal result and returns exit code `1` when any item failed, while preserving
794
+ the complete `succeeded` and `failed` arrays on stdout. Re-running an interrupted
795
+ command with the same input resumes the same server operation.
770
796
 
771
797
  ## Development
772
798
 
773
799
  ```bash
774
800
  npm ci
775
801
  npm run verify
802
+ npm run release:preflight
776
803
  ```
777
804
 
778
805
  `npm run test:package` packs the exact npm artifact, installs it into a clean
779
806
  temporary project, and runs the installed binary.
780
807
 
808
+ `npm run release:preflight` reports the local, packed, globally installed, and
809
+ npm-registry versions, then exercises every parser command's nested help. It
810
+ fails when a parent help page stops advertising one of its child commands.
811
+
781
812
  ## Releasing
782
813
 
783
814
  Releases are published only through the trusted `Publish npm release` GitHub
package/dist/args.js CHANGED
@@ -486,7 +486,7 @@ function parseBudget(args, baseUrl) {
486
486
  ? 'budget move'
487
487
  : 'budget';
488
488
  const { values, apply } = parseNamedOptions(args, commandLabel, new Set(status
489
- ? ['--scope']
489
+ ? ['--scope', '--period']
490
490
  : update
491
491
  ? ['--scope', '--period', '--input']
492
492
  : move
@@ -498,17 +498,13 @@ function parseBudget(args, baseUrl) {
498
498
  if (scope !== 'personal' && scope !== 'joint') {
499
499
  throw new UsageError('--scope must be personal or joint');
500
500
  }
501
- if (status) {
502
- return withBaseUrl({
503
- command: 'budget-status',
504
- scope: scope,
505
- }, baseUrl);
506
- }
507
501
  const period = values.get('--period');
508
502
  const common = {
509
503
  scope: scope,
510
504
  ...(period === undefined ? {} : { periodKey: parseGoalMonthKey(period, '--period') }),
511
505
  };
506
+ if (status)
507
+ return withBaseUrl({ command: 'budget-status', ...common }, baseUrl);
512
508
  if (!update && !move)
513
509
  return withBaseUrl({ command: 'budget', ...common }, baseUrl);
514
510
  if (move) {
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import nodePath from 'node:path';
3
4
  import { parseArgs, resolveBaseUrl, } from './args.js';
4
5
  import { ICON_KEYS } from './category-metadata.js';
5
- import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
6
+ import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
6
7
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
7
8
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
8
- export const CLI_VERSION = '0.16.0';
9
+ export const CLI_VERSION = '0.18.0';
9
10
  const REQUEST_TIMEOUT_MS = 60_000;
10
11
  const MAX_CONTRACT_PDF_BYTES = 6_000_000;
11
12
  const API_ORIGIN_HELP_LINES = [
@@ -16,6 +17,8 @@ const API_ORIGIN_HELP_LINES = [
16
17
  ' Use an origin-only URL with no credentials, path, query, or fragment.',
17
18
  ' HTTPS is required except for localhost development.',
18
19
  ];
20
+ const ASSIGNMENT_REQUEST_ATTEMPTS = 3;
21
+ const ASSIGNMENT_RETRY_DELAY_MS = 500;
19
22
  export function usageText() {
20
23
  return [
21
24
  'Sloth Agent CLI',
@@ -32,7 +35,7 @@ export function usageText() {
32
35
  ' sloth-agent accounts remove --account-ref REF [--apply]',
33
36
  ' sloth-agent investments [--account-ref REF] [--base-url URL]',
34
37
  ' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
35
- ' sloth-agent budget status --scope personal|joint [--base-url URL]',
38
+ ' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
36
39
  ' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
37
40
  ' --input budget.json [--apply] [--base-url URL]',
38
41
  ' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
@@ -458,7 +461,7 @@ export function budgetHelpText() {
458
461
  '',
459
462
  'Options:',
460
463
  ' --scope personal|joint Required. Budget ownership scope.',
461
- ' --period YYYY-MM Optional. Defaults to the current Sloth budget period.',
464
+ ' --period YYYY-MM Optional. Month containing the period start; defaults to the current Sloth period.',
462
465
  ' --base-url URL Optional. Override the API origin.',
463
466
  ' -h, --help Show this help.',
464
467
  ...API_ORIGIN_HELP_LINES,
@@ -477,29 +480,28 @@ export function budgetStatusHelpText() {
477
480
  return [
478
481
  'Sloth Agent CLI — budget status',
479
482
  '',
480
- 'Read assigned, spent, and available money for the current Sloth budget period.',
483
+ 'Read booked activity and any trustworthy budget for one Sloth budget period.',
481
484
  '',
482
485
  'Usage:',
483
- ' sloth-agent budget status --scope personal|joint [--base-url URL]',
486
+ ' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
484
487
  '',
485
488
  'Options:',
486
489
  ' --scope personal|joint Required. Budget ownership scope.',
490
+ ' --period YYYY-MM Optional. Month containing the period start; defaults to the current Sloth period.',
487
491
  ' --base-url URL Optional. Override the API origin.',
488
492
  ' -h, --help Show this help.',
489
493
  ...API_ORIGIN_HELP_LINES,
490
494
  '',
491
495
  'Access and freshness:',
492
496
  ' This command is read-only, requires agent:read, and never changes the budget.',
493
- ' The server applies its normal once-per-UTC-day automatic transaction refresh policy.',
494
- ' Inspect refresh.status and refresh.reason before relying on the result.',
497
+ ' Current periods use the normal transaction refresh policy; historical periods are cache-only.',
498
+ ' A historical response returns refresh as null.',
495
499
  '',
496
500
  'Output:',
497
- ' categories[].assignedPence is the money assigned to the category.',
498
- ' categories[].spentPence is signed booked activity; refunds reduce it.',
499
- ' categories[].availablePence equals assignedPence minus spentPence.',
500
- ' Negative availablePence means the category is over budget.',
501
- ' activity contains the period dates, transaction count, uncategorizedSpentPence,',
502
- ' and unmappedSpentPence. Review either nonzero value before moving money.',
501
+ ' activity.categories includes Income, Transfer, None, and ordinary categories.',
502
+ ' moneyInPence and moneyOutPence are nonnegative; netPence is money in minus money out.',
503
+ ' A transaction with no category appears under activity.uncategorized.',
504
+ ' budget contains the period plan and balances, or budget is null when no trustworthy plan exists.',
503
505
  ].join('\n');
504
506
  }
505
507
  export function budgetUpdateHelpText() {
@@ -654,13 +656,19 @@ export function assignHelpText() {
654
656
  ' the payload it would send. It does not contact Sloth Money, verify the',
655
657
  ' transactionRef or category values, or write anything.',
656
658
  ' A successful preview does not guarantee that applying it will succeed.',
657
- ' With --apply, assignments are best-effort; any failed item makes the command',
658
- ' exit with code 1 while the complete result remains available on stdout.',
659
+ ' With --apply, the CLI submits one durable server operation, then polls authenticated status',
660
+ ' until every item finishes. Transient submission and status failures are retried.',
661
+ ' Re-run the same command with the same assignment input after an interruption;',
662
+ ' the CLI resumes the same operation instead of duplicating its work.',
663
+ ' Operation status and item receipts remain available on the server for seven days.',
664
+ ' Assignments are best-effort; any failed item makes the command exit with code 1',
665
+ ' while the complete terminal result remains available on stdout.',
659
666
  ' Applying requires a write-enabled token created with Allow changes.',
660
667
  '',
661
668
  'Input:',
662
669
  ' The top-level object must contain an assignments array.',
663
670
  ' Each assignment requires transactionRef and at least one category operation or sharing object.',
671
+ ' Each transactionRef may appear only once in the assignments array.',
664
672
  ' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
665
673
  ' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
666
674
  ' Omitted split values use saved defaults for a first share and preserve an existing split.',
@@ -702,7 +710,7 @@ export function assignHelpText() {
702
710
  '',
703
711
  'Output:',
704
712
  ' Preview mode returns dryRun, endpoint, and the validated payload.',
705
- ' Apply mode returns succeeded and failed assignment arrays.',
713
+ ' Apply mode waits for the durable operation and returns succeeded and failed arrays.',
706
714
  ' Successful assignments update the original transaction. See the result in',
707
715
  ' Sloth Money → Transactions or read the transaction again through the CLI.',
708
716
  ' Assignments do not create a separate list.',
@@ -953,11 +961,11 @@ export function rulesHelpText() {
953
961
  'Rules do not create transactions or recurring predictions.',
954
962
  '',
955
963
  'Commands:',
956
- ' rules list List every saved notification rule.',
957
- ' rules get Read the rule for one transaction.',
958
- ' rules set Preview or save a rule for one transaction.',
959
- ' rules delete Preview or remove a rule.',
960
- ' rules scan-contract Validate or scan a PDF for its renewal date.',
964
+ ' sloth-agent rules list List every saved notification rule.',
965
+ ' sloth-agent rules get Read the rule for one transaction.',
966
+ ' sloth-agent rules set Preview or save a rule for one transaction.',
967
+ ' sloth-agent rules delete Preview or remove a rule.',
968
+ ' sloth-agent rules scan-contract Validate or scan a PDF for its renewal date.',
961
969
  '',
962
970
  'Use the exact transactionRef returned by sloth-agent transactions.',
963
971
  ...API_ORIGIN_HELP_LINES,
@@ -1407,6 +1415,99 @@ function requestHeaders(token) {
1407
1415
  'User-Agent': `sloth-agent/${CLI_VERSION}`,
1408
1416
  };
1409
1417
  }
1418
+ function assignmentIdempotencyKey(payload) {
1419
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
1420
+ }
1421
+ function isRetryableAssignmentRequestError(error) {
1422
+ if (error instanceof ApiError) {
1423
+ return error.status !== undefined
1424
+ && [408, 425, 429, 499, 500, 502, 503, 504].includes(error.status);
1425
+ }
1426
+ return error instanceof TypeError
1427
+ || (error instanceof Error && error.name === 'AbortError');
1428
+ }
1429
+ async function withAssignmentRequestRecovery(request, sleep) {
1430
+ let lastError;
1431
+ for (let attempt = 1; attempt <= ASSIGNMENT_REQUEST_ATTEMPTS; attempt += 1) {
1432
+ try {
1433
+ return await request();
1434
+ }
1435
+ catch (error) {
1436
+ lastError = error;
1437
+ if (!isRetryableAssignmentRequestError(error) || attempt === ASSIGNMENT_REQUEST_ATTEMPTS) {
1438
+ throw error;
1439
+ }
1440
+ await sleep(ASSIGNMENT_RETRY_DELAY_MS * attempt);
1441
+ }
1442
+ }
1443
+ throw lastError;
1444
+ }
1445
+ async function parseAssignmentOperationHttpResponse(response, token, expectedStatus) {
1446
+ const data = await parseHttpResponse(response, token);
1447
+ if (response.status !== expectedStatus) {
1448
+ throw new ApiError(`Agent API returned status ${response.status}; expected ${expectedStatus}`, response.status);
1449
+ }
1450
+ return parseAssignmentOperationResponse(data);
1451
+ }
1452
+ function assertAssignmentOperationMatchesPayload(operation, payload, expectedOperationId) {
1453
+ if (operation.itemCount !== payload.assignments.length
1454
+ || (expectedOperationId !== undefined && operation.operationId !== expectedOperationId)
1455
+ || (operation.status === 'completed'
1456
+ && operation.results?.some((result, index) => (result.transactionRef !== payload.assignments[index]?.transactionRef)))) {
1457
+ throw new ApiError('Assignment operation response did not match the submitted assignment order');
1458
+ }
1459
+ }
1460
+ async function applyAssignments(fetchImplementation, sleep, baseUrl, token, payload) {
1461
+ const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
1462
+ const idempotencyKey = assignmentIdempotencyKey(payload);
1463
+ let operation;
1464
+ try {
1465
+ operation = await withAssignmentRequestRecovery(async () => {
1466
+ const response = await fetchImplementation(endpoint, {
1467
+ method: 'POST',
1468
+ headers: {
1469
+ ...requestHeaders(token),
1470
+ 'Content-Type': 'application/json',
1471
+ 'Idempotency-Key': idempotencyKey,
1472
+ },
1473
+ body: JSON.stringify(payload),
1474
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1475
+ });
1476
+ return parseAssignmentOperationHttpResponse(response, token, 202);
1477
+ }, sleep);
1478
+ }
1479
+ catch (error) {
1480
+ if (!isRetryableAssignmentRequestError(error))
1481
+ throw error;
1482
+ throw new ApiError('Assignment submission could not be confirmed. '
1483
+ + 'Re-run the same command with the same assignment input to resume it.');
1484
+ }
1485
+ assertAssignmentOperationMatchesPayload(operation, payload);
1486
+ const operationId = operation.operationId;
1487
+ while (operation.status !== 'completed') {
1488
+ await sleep(operation.pollAfterMs);
1489
+ const statusEndpoint = `${endpoint}/${encodeURIComponent(operationId)}`;
1490
+ try {
1491
+ const nextOperation = await withAssignmentRequestRecovery(async () => {
1492
+ const response = await fetchImplementation(statusEndpoint, {
1493
+ method: 'GET',
1494
+ headers: requestHeaders(token),
1495
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1496
+ });
1497
+ return parseAssignmentOperationHttpResponse(response, token, 200);
1498
+ }, sleep);
1499
+ assertAssignmentOperationMatchesPayload(nextOperation, payload, operationId);
1500
+ operation = nextOperation;
1501
+ }
1502
+ catch (error) {
1503
+ if (!isRetryableAssignmentRequestError(error))
1504
+ throw error;
1505
+ throw new ApiError('Assignment status could not be recovered. '
1506
+ + 'Re-run the same command with the same assignment input to resume it.');
1507
+ }
1508
+ }
1509
+ return toLegacyAssignmentResponse(operation);
1510
+ }
1410
1511
  async function validateCredentialRemotely(fetchImplementation, origin, token) {
1411
1512
  const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
1412
1513
  method: 'GET',
@@ -1442,6 +1543,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1442
1543
  ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
1443
1544
  const readSecret = options.readSecret ?? defaultReadSecret;
1444
1545
  const readStdin = options.readStdin ?? defaultReadStdin;
1546
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
1547
+ setTimeout(resolve, milliseconds);
1548
+ }));
1445
1549
  const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
1446
1550
  const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
1447
1551
  let token;
@@ -1884,17 +1988,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1884
1988
  }
1885
1989
  if (parsed.command === 'assign') {
1886
1990
  const payload = assignmentPayload;
1887
- const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
1888
- const response = await fetchImplementation(endpoint, {
1889
- method: 'POST',
1890
- headers: {
1891
- ...headers,
1892
- 'Content-Type': 'application/json',
1893
- },
1894
- body: JSON.stringify(payload),
1895
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1896
- });
1897
- const data = parseApiResponse('assign', await parseHttpResponse(response, token));
1991
+ const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
1898
1992
  writeJson(writeStdout, data);
1899
1993
  return hasFailures(data) ? 1 : 0;
1900
1994
  }
@@ -1970,7 +2064,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1970
2064
  }
1971
2065
  if (parsed.command === 'budget' || parsed.command === 'budget-status') {
1972
2066
  const query = new URLSearchParams({ scope: parsed.scope });
1973
- if (parsed.command === 'budget' && parsed.periodKey !== undefined) {
2067
+ if (parsed.periodKey !== undefined) {
1974
2068
  query.set('periodKey', parsed.periodKey);
1975
2069
  }
1976
2070
  const response = await fetchImplementation(`${baseUrl}/api/agent/v1/${parsed.command === 'budget' ? 'budgets' : 'budget-status'}?${query.toString()}`, {
package/dist/contracts.js CHANGED
@@ -269,7 +269,15 @@ export function validateAssignmentPayload(value) {
269
269
  if (payload.assignments.length < 1 || payload.assignments.length > 100) {
270
270
  throw new UsageError('assignments must contain between 1 and 100 items');
271
271
  }
272
- return { assignments: payload.assignments.map(validateAssignment) };
272
+ const assignments = payload.assignments.map(validateAssignment);
273
+ const transactionRefs = new Set();
274
+ for (const assignment of assignments) {
275
+ if (transactionRefs.has(assignment.transactionRef)) {
276
+ throw new UsageError('Each assignments[].transactionRef must be unique');
277
+ }
278
+ transactionRefs.add(assignment.transactionRef);
279
+ }
280
+ return { assignments };
273
281
  }
274
282
  export function validateBudgetUpdatePayload(value) {
275
283
  const payload = requireObject(value, 'budget update payload');
@@ -505,6 +513,73 @@ function isAssignmentResponse(value) {
505
513
  && typeof item.error === 'string'
506
514
  && (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
507
515
  }
516
+ function isAssignmentOperationResult(value) {
517
+ if (!isObject(value) || typeof value.transactionRef !== 'string')
518
+ return false;
519
+ const { status, ...legacyResult } = value;
520
+ if (status === 'succeeded') {
521
+ return isAssignmentResponse({ succeeded: [legacyResult], failed: [] });
522
+ }
523
+ if (status === 'failed') {
524
+ return isAssignmentResponse({ succeeded: [], failed: [legacyResult] });
525
+ }
526
+ return false;
527
+ }
528
+ export function parseAssignmentOperationResponse(value) {
529
+ const validBase = isObject(value)
530
+ && hasOnlyFields(value, [
531
+ 'operationId', 'status', 'itemCount', 'completedCount', 'failedCount',
532
+ 'expiresAt', 'pollAfterMs', 'results',
533
+ ])
534
+ && typeof value.operationId === 'string'
535
+ && /^[a-f0-9]{64}$/.test(value.operationId)
536
+ && (value.status === 'pending'
537
+ || value.status === 'processing'
538
+ || value.status === 'completed')
539
+ && Number.isSafeInteger(value.itemCount)
540
+ && Number(value.itemCount) >= 1
541
+ && Number(value.itemCount) <= 100
542
+ && isNonnegativeSafeInteger(value.completedCount)
543
+ && Number(value.completedCount) <= Number(value.itemCount)
544
+ && isNonnegativeSafeInteger(value.failedCount)
545
+ && Number(value.failedCount) <= Number(value.completedCount)
546
+ && isIsoDateTime(value.expiresAt)
547
+ && isNonnegativeSafeInteger(value.pollAfterMs)
548
+ && Number(value.pollAfterMs) >= 100
549
+ && Number(value.pollAfterMs) <= 10_000;
550
+ if (!validBase) {
551
+ throw new ApiError('Invalid assignment operation response from the Agent API');
552
+ }
553
+ const isComplete = value.status === 'completed';
554
+ const resultsAreValid = isComplete
555
+ ? (Number(value.completedCount) === Number(value.itemCount)
556
+ && Array.isArray(value.results)
557
+ && value.results.length === Number(value.itemCount)
558
+ && value.results.every(isAssignmentOperationResult)
559
+ && value.results.filter((result) => result.status === 'failed').length
560
+ === Number(value.failedCount))
561
+ : value.results === undefined;
562
+ if (!resultsAreValid) {
563
+ throw new ApiError('Invalid assignment operation response from the Agent API');
564
+ }
565
+ return value;
566
+ }
567
+ export function toLegacyAssignmentResponse(value) {
568
+ const operation = parseAssignmentOperationResponse(value);
569
+ if (operation.status !== 'completed' || !operation.results) {
570
+ throw new ApiError('Assignment operation is not complete');
571
+ }
572
+ const succeeded = [];
573
+ const failed = [];
574
+ for (const result of operation.results) {
575
+ const { status, ...legacyResult } = result;
576
+ if (status === 'succeeded')
577
+ succeeded.push(legacyResult);
578
+ else
579
+ failed.push(legacyResult);
580
+ }
581
+ return { succeeded, failed };
582
+ }
508
583
  function isHttpUrl(value) {
509
584
  if (typeof value !== 'string')
510
585
  return false;
@@ -591,6 +666,12 @@ function isBudgetCategory(value) {
591
666
  && Array.isArray(value.lineItems)
592
667
  && value.lineItems.every(isBudgetLineItem));
593
668
  }
669
+ function isBudgetFunding(value) {
670
+ return value === null || (isObject(value)
671
+ && hasOnlyFields(value, ['toAssignPence', 'nextPeriodReservePence'])
672
+ && isSafeInteger(value.toAssignPence)
673
+ && isSafeInteger(value.nextPeriodReservePence));
674
+ }
594
675
  function isBudgetResponse(value) {
595
676
  return (isObject(value)
596
677
  && hasOnlyFields(value, [
@@ -611,15 +692,11 @@ function isBudgetResponse(value) {
611
692
  && isCurrency(value.currency)
612
693
  && typeof value.effectiveFromPeriodKey === 'string'
613
694
  && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
614
- && (value.funding === null
615
- || (isObject(value.funding)
616
- && hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
617
- && isSafeInteger(value.funding.toAssignPence)
618
- && isSafeInteger(value.funding.nextPeriodReservePence)))
695
+ && isBudgetFunding(value.funding)
619
696
  && Array.isArray(value.categories)
620
697
  && value.categories.every(isBudgetCategory));
621
698
  }
622
- function isBudgetStatusResponse(value) {
699
+ function isLegacyBudgetStatusResponse(value) {
623
700
  if (!isObject(value)
624
701
  || !hasOnlyFields(value, [
625
702
  'scope',
@@ -640,11 +717,7 @@ function isBudgetStatusResponse(value) {
640
717
  && isCurrency(value.currency)
641
718
  && typeof value.effectiveFromPeriodKey === 'string'
642
719
  && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
643
- && (value.funding === null
644
- || (isObject(value.funding)
645
- && hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
646
- && isSafeInteger(value.funding.toAssignPence)
647
- && isSafeInteger(value.funding.nextPeriodReservePence)))
720
+ && isBudgetFunding(value.funding)
648
721
  && isObject(value.activity)
649
722
  && hasOnlyFields(value.activity, [
650
723
  'startDate',
@@ -681,6 +754,82 @@ function isBudgetStatusResponse(value) {
681
754
  && Number.isSafeInteger(category.assignedPence - category.spentPence)
682
755
  && category.availablePence === category.assignedPence - category.spentPence)));
683
756
  }
757
+ function isActivityAmounts(value) {
758
+ return isObject(value)
759
+ && hasOnlyFields(value, ['moneyInPence', 'moneyOutPence', 'netPence'])
760
+ && isNonnegativeSafeInteger(value.moneyInPence)
761
+ && isNonnegativeSafeInteger(value.moneyOutPence)
762
+ && isSafeInteger(value.netPence)
763
+ && Number.isSafeInteger(value.moneyInPence - value.moneyOutPence)
764
+ && value.netPence === value.moneyInPence - value.moneyOutPence;
765
+ }
766
+ function isBudgetActivityStatusResponse(value) {
767
+ if (!isObject(value) || !hasOnlyFields(value, [
768
+ 'scope', 'periodKey', 'periodStatus', 'currency', 'period', 'refresh', 'activity', 'budget',
769
+ ]))
770
+ return false;
771
+ if (!isObject(value.period) || !hasOnlyFields(value.period, [
772
+ 'startDate', 'endDate', 'dateRangeSource',
773
+ ]))
774
+ return false;
775
+ if (!isObject(value.activity) || !hasOnlyFields(value.activity, [
776
+ 'transactionCount', 'categories', 'uncategorized',
777
+ ]))
778
+ return false;
779
+ const activityCategories = value.activity.categories;
780
+ if (!Array.isArray(activityCategories) || !activityCategories.every((category) => (isObject(category)
781
+ && hasOnlyFields(category, ['id', 'name', 'moneyInPence', 'moneyOutPence', 'netPence'])
782
+ && typeof category.id === 'string'
783
+ && category.id.trim().length > 0
784
+ && typeof category.name === 'string'
785
+ && category.name.trim().length > 0
786
+ && isActivityAmounts({
787
+ moneyInPence: category.moneyInPence,
788
+ moneyOutPence: category.moneyOutPence,
789
+ netPence: category.netPence,
790
+ }))))
791
+ return false;
792
+ const budget = value.budget;
793
+ if (budget !== null && (!isObject(budget)
794
+ || !hasOnlyFields(budget, ['effectiveFromPeriodKey', 'funding', 'categories'])
795
+ || typeof budget.effectiveFromPeriodKey !== 'string'
796
+ || !/^\d{4}-(0[1-9]|1[0-2])$/.test(budget.effectiveFromPeriodKey)
797
+ || !isBudgetFunding(budget.funding)
798
+ || !Array.isArray(budget.categories)
799
+ || !budget.categories.every((category) => (isObject(category)
800
+ && hasOnlyFields(category, [
801
+ 'id', 'name', 'plannedPence', 'assignedPence', 'spentPence', 'availablePence',
802
+ ])
803
+ && typeof category.id === 'string'
804
+ && category.id.trim().length > 0
805
+ && typeof category.name === 'string'
806
+ && category.name.trim().length > 0
807
+ && isNonnegativeSafeInteger(category.plannedPence)
808
+ && isSafeInteger(category.assignedPence)
809
+ && isSafeInteger(category.spentPence)
810
+ && isSafeInteger(category.availablePence)
811
+ && Number.isSafeInteger(category.assignedPence - category.spentPence)
812
+ && category.availablePence === category.assignedPence - category.spentPence))))
813
+ return false;
814
+ return (value.scope === 'personal' || value.scope === 'joint')
815
+ && typeof value.periodKey === 'string'
816
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
817
+ && (value.periodStatus === 'current' || value.periodStatus === 'historical')
818
+ && isCurrency(value.currency)
819
+ && isIsoDate(value.period.startDate)
820
+ && isIsoDate(value.period.endDate)
821
+ && value.period.startDate <= value.period.endDate
822
+ && (value.period.dateRangeSource === 'stored'
823
+ || value.period.dateRangeSource === 'legacy_settings_fallback')
824
+ && isNonnegativeSafeInteger(value.activity.transactionCount)
825
+ && isActivityAmounts(value.activity.uncategorized)
826
+ && (value.periodStatus === 'historical'
827
+ ? value.refresh === null
828
+ : isRefreshStatus(value.refresh));
829
+ }
830
+ function isBudgetStatusResponse(value) {
831
+ return isLegacyBudgetStatusResponse(value) || isBudgetActivityStatusResponse(value);
832
+ }
684
833
  function isBudgetMovementResponse(value) {
685
834
  return (isObject(value)
686
835
  && hasOnlyFields(value, [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "test": "vitest run",
18
18
  "test:package": "node scripts/test-package.mjs",
19
19
  "test:registry": "node scripts/test-registry-package.mjs",
20
+ "release:preflight": "node scripts/release-preflight.mjs",
20
21
  "typecheck": "tsc --noEmit",
21
22
  "verify": "npm run lint && npm run typecheck && npm test && npm run build && npm run test:package"
22
23
  },