@slothmoney/agent-cli 0.19.0 → 0.21.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 - 2026-08-28
4
+
5
+ - Add `scenarios` commands to list, create, update, activate, and delete the
6
+ month-anchored choices used by Goal forecasts.
7
+ - Preview every scenario change against Sloth's canonical planner with zero
8
+ writes; `--apply` remains the explicit write boundary.
9
+ - Create the common No/Yes choice directly, support recurring and one-off
10
+ account contributions, and return the recalculated Goal roadmap.
11
+
12
+ ## 0.20.0 - 2026-08-27
13
+
14
+ - Add `portfolio` views for personal, partner-shared, and combined household
15
+ savings and investments.
16
+ - Add account-level partner visibility updates for private, balance-only, and
17
+ balance-and-holdings sharing.
18
+ - Keep portfolio results strict, machine-readable, and explicit about refresh
19
+ status and excluded currencies.
20
+
3
21
  ## 0.19.0 - 2026-08-26
4
22
 
5
23
  - Make every Goal use one explicit personal Goal-funding account and expose
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, move assigned budget money, update planned amounts, categorise transactions, and configure payment notifications through the
3
+ Use your own agent to inspect personal and household accounts, investments, and budgets, manage goals and forecast scenarios, move assigned budget money, update planned amounts, categorise transactions, and configure payment notifications 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.17.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.21.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -24,10 +24,10 @@ 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`, `investments`,
28
- `budget`, `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
27
+ New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`, `portfolio`,
28
+ `budget`, `categories`, `transactions`, `goals`, and `scenarios` list. Enable **Allow changes** when
29
29
  creating the token only if the CLI must apply assignments, manage categories
30
- or line items, move assigned budget money, 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 and scenarios. Token
31
31
  permissions cannot be changed later - revoke and reissue the token instead.
32
32
 
33
33
  ### Local computer
@@ -103,6 +103,7 @@ options, output, and examples. For example:
103
103
  ```bash
104
104
  sloth-agent auth login --help
105
105
  sloth-agent accounts --help
106
+ sloth-agent portfolio --help
106
107
  sloth-agent budget --help
107
108
  sloth-agent budget status --help
108
109
  sloth-agent budget update --help
@@ -121,6 +122,9 @@ sloth-agent goals create --help
121
122
  sloth-agent goals update --help
122
123
  sloth-agent goals mark-spent --help
123
124
  sloth-agent goals restore --help
125
+ sloth-agent scenarios create --help
126
+ sloth-agent scenarios update --help
127
+ sloth-agent scenarios activate --help
124
128
  sloth-agent ask-partner --help
125
129
  ```
126
130
 
@@ -584,7 +588,8 @@ sloth-agent accounts
584
588
  The command is read-only and cache-only: it does not refresh linked banks or
585
589
  change account data. Each result contains an opaque `accountRef`, personal or
586
590
  joint ownership, connected or manual source, native balance/currency when
587
- known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalFundingAccount`.
591
+ known, `lastBalanceUpdatedAt`, `connectionState`, `isGoalFundingAccount`, and
592
+ `partnerVisibility`.
588
593
  Missing values are JSON
589
594
  `null`; currencies are never converted or combined. Partner personal accounts
590
595
  are excluded, while enabled shared joint accounts follow Sloth's existing
@@ -604,7 +609,7 @@ Copy the value from `sloth-agent accounts`. Account references are the public
604
609
  account identifier for transaction filtering.
605
610
 
606
611
  Account changes are previews unless `--apply` is present. Connected accounts
607
- support only Goal-funding eligibility. Manual current accounts support their
612
+ support Goal-funding eligibility and partner visibility. Manual current accounts support their
608
613
  institution, name, currency, and ownership. Manual balance accounts also
609
614
  support balance, Savings/Investments type, and Goal-funding eligibility.
610
615
  Partner-owned shared accounts return an explanatory error.
@@ -618,14 +623,31 @@ sloth-agent accounts update \
618
623
  --ownership individual \
619
624
  --balance-amount 12500.75 \
620
625
  --account-type investments \
621
- --goal-funding-account false
626
+ --goal-funding-account false \
627
+ --partner-visibility holdings
622
628
 
623
629
  sloth-agent accounts update \
624
630
  --account-ref sloth_account_v1_... \
625
- --goal-funding-account false \
631
+ --partner-visibility balance \
626
632
  --apply
627
633
  ```
628
634
 
635
+ Read the same current position from your, your partner's, or the combined
636
+ household perspective:
637
+
638
+ ```bash
639
+ sloth-agent portfolio
640
+ sloth-agent portfolio --view partner
641
+ sloth-agent portfolio --view household
642
+ ```
643
+
644
+ The command waits up to 45 seconds for eligible linked balances to refresh,
645
+ then returns cached data if work continues. Partner accounts appear only when
646
+ their owner has shared the balance or linked holdings. Sharing is for household
647
+ planning only. It does not change account ownership, transaction access, Goal
648
+ funding, or who can move money. Totals use the viewer's budget currency and
649
+ exclude other native currencies without converting them.
650
+
629
651
  Archive an owned manual account. The account disappears from active Sloth
630
652
  surfaces, but its underlying records are retained. Repeating an applied removal
631
653
  is safe and returns `changed: false`.
@@ -729,6 +751,52 @@ one-based `priority`; `1` is highest. Moving one goal automatically shifts the
729
751
  goals between its old and new positions. Sloth recalculates the active-scenario
730
752
  roadmap before every applied Goal mutation and returns the updated forecast.
731
753
 
754
+ List the scenarios that supply assumptions to that roadmap:
755
+
756
+ ```bash
757
+ sloth-agent scenarios
758
+ ```
759
+
760
+ Create a monthly contribution choice. Preview is the default and performs zero
761
+ writes; add `--apply` after reviewing the returned scenario and recalculated
762
+ Goals:
763
+
764
+ ```bash
765
+ sloth-agent scenarios create \
766
+ --month 2026-09 \
767
+ --name "Deposit £100 into the shopping pot each month?" \
768
+ --account-ref sloth_account_v1_... \
769
+ --recurring-amount 100
770
+ ```
771
+
772
+ Creation adds No and Yes options and activates Yes. The recurring contribution
773
+ continues until a later active scenario changes it. A one-off amount applies
774
+ only in the scenario month. Scenarios alter the forecast; they do not move
775
+ money.
776
+
777
+ Use stable IDs from `scenarios` output to edit or select an option:
778
+
779
+ ```bash
780
+ sloth-agent scenarios update \
781
+ --month 2026-09 \
782
+ --option-id yes \
783
+ --account-ref sloth_account_v1_... \
784
+ --recurring-amount 125 \
785
+ --apply
786
+
787
+ sloth-agent scenarios activate \
788
+ --month 2026-09 \
789
+ --option-id no \
790
+ --apply
791
+
792
+ sloth-agent scenarios delete --month 2026-09 --apply
793
+ ```
794
+
795
+ For recurring contributions, `--recurring-amount 0` explicitly stops the
796
+ earlier amount. `--clear-recurring` removes this month's override, so the
797
+ earlier recurring amount continues. Contribution updates use the active option
798
+ when `--option-id` is omitted.
799
+
732
800
  Read uncategorised contributions to the joint budget:
733
801
 
734
802
  ```bash
package/dist/args.js CHANGED
@@ -67,6 +67,16 @@ function parsePositiveDecimalAmount(value, name) {
67
67
  }
68
68
  return amount;
69
69
  }
70
+ function parseNonNegativeDecimalAmount(value, name) {
71
+ if (!/^\d+(?:\.\d{1,2})?$/.test(value)) {
72
+ throw new UsageError(`${name} must be zero or a positive amount with at most two decimal places`);
73
+ }
74
+ const amount = Number(value);
75
+ if (!Number.isFinite(amount) || amount < 0) {
76
+ throw new UsageError(`${name} must be zero or a positive amount with at most two decimal places`);
77
+ }
78
+ return amount;
79
+ }
70
80
  function parsePositiveAmountPence(value, name) {
71
81
  validatePositiveDecimalAmount(value, name);
72
82
  const [wholePounds, fractionalPounds = ''] = value.split('.');
@@ -390,6 +400,7 @@ function parseAccounts(args, baseUrl) {
390
400
  '--balance-amount',
391
401
  '--account-type',
392
402
  '--goal-funding-account',
403
+ '--partner-visibility',
393
404
  ]));
394
405
  const institutionName = values.get('--institution-name');
395
406
  const accountName = values.get('--account-name');
@@ -398,6 +409,7 @@ function parseAccounts(args, baseUrl) {
398
409
  const balanceValue = values.get('--balance-amount');
399
410
  const accountTypeValue = values.get('--account-type');
400
411
  const sourceValue = values.get('--goal-funding-account');
412
+ const partnerVisibility = values.get('--partner-visibility');
401
413
  if (currencyValue !== undefined && !/^[A-Za-z]{3}$/.test(currencyValue)) {
402
414
  throw new UsageError('--currency must be a three-letter currency code');
403
415
  }
@@ -414,6 +426,12 @@ function parseAccounts(args, baseUrl) {
414
426
  if (sourceValue !== undefined && sourceValue !== 'true' && sourceValue !== 'false') {
415
427
  throw new UsageError('--goal-funding-account must be true or false');
416
428
  }
429
+ if (partnerVisibility !== undefined
430
+ && partnerVisibility !== 'private'
431
+ && partnerVisibility !== 'balance'
432
+ && partnerVisibility !== 'holdings') {
433
+ throw new UsageError('--partner-visibility must be private, balance, or holdings');
434
+ }
417
435
  const update = {
418
436
  ...(institutionName === undefined
419
437
  ? {}
@@ -430,6 +448,9 @@ function parseAccounts(args, baseUrl) {
430
448
  ? {}
431
449
  : { accountType: accountTypeValue }),
432
450
  ...(sourceValue === undefined ? {} : { isGoalFundingAccount: sourceValue === 'true' }),
451
+ ...(partnerVisibility === undefined ? {} : {
452
+ partnerVisibility: partnerVisibility,
453
+ }),
433
454
  };
434
455
  if (Object.keys(update).length === 0) {
435
456
  throw new UsageError('accounts update requires at least one field to update');
@@ -471,6 +492,16 @@ function parseInvestments(args, baseUrl) {
471
492
  }
472
493
  return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
473
494
  }
495
+ function parsePortfolio(args, baseUrl) {
496
+ const { values, apply } = parseNamedOptions(args, 'portfolio', new Set(['--view']));
497
+ if (apply)
498
+ throw new UsageError('portfolio does not accept --apply');
499
+ const view = values.get('--view') ?? 'mine';
500
+ if (view !== 'mine' && view !== 'partner' && view !== 'household') {
501
+ throw new UsageError('--view must be mine, partner, or household');
502
+ }
503
+ return withBaseUrl({ command: 'portfolio', view }, baseUrl);
504
+ }
474
505
  function parseBudget(args, baseUrl) {
475
506
  const subcommand = args[0] === 'status' || args[0] === 'update' || args[0] === 'move'
476
507
  ? args.shift()
@@ -843,6 +874,226 @@ function parseGoals(args, baseUrl) {
843
874
  }
844
875
  throw new UsageError(`Unknown goals command: ${subcommand}`);
845
876
  }
877
+ function parseScenarioText(value, option, maxLength = 60) {
878
+ const text = value.trim();
879
+ if (text.length > maxLength) {
880
+ throw new UsageError(`${option} must be at most ${maxLength} characters`);
881
+ }
882
+ return text;
883
+ }
884
+ function parseScenarios(args, baseUrl) {
885
+ const subcommand = args.shift();
886
+ if (subcommand === undefined || subcommand === 'list') {
887
+ if (args.length > 0) {
888
+ throw new UsageError(`Unknown scenarios list option: ${args[0]}`);
889
+ }
890
+ return withBaseUrl({ command: 'scenarios-list' }, baseUrl);
891
+ }
892
+ if (subcommand === 'create') {
893
+ let monthKey;
894
+ let name;
895
+ let accountRef;
896
+ let recurringAmount;
897
+ let oneOffAmount;
898
+ let apply = false;
899
+ for (let index = 0; index < args.length; index += 1) {
900
+ const argument = args[index];
901
+ if (argument === '--apply') {
902
+ if (apply)
903
+ throw new UsageError('--apply may only be provided once');
904
+ apply = true;
905
+ continue;
906
+ }
907
+ const [option, inlineValue] = argument.includes('=')
908
+ ? argument.split(/=(.*)/s, 2)
909
+ : [argument, undefined];
910
+ if (option !== '--month'
911
+ && option !== '--name'
912
+ && option !== '--account-ref'
913
+ && option !== '--recurring-amount'
914
+ && option !== '--one-off-amount') {
915
+ throw new UsageError(`Unknown scenarios create option: ${argument}`);
916
+ }
917
+ const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
918
+ if (inlineValue === undefined)
919
+ index += 1;
920
+ if (option === '--month') {
921
+ monthKey = setOnce(monthKey, parseGoalMonthKey(value, option), option);
922
+ }
923
+ else if (option === '--name') {
924
+ name = setOnce(name, parseScenarioText(value, option), option);
925
+ }
926
+ else if (option === '--account-ref') {
927
+ accountRef = setOnce(accountRef, parseAccountRef(value), option);
928
+ }
929
+ else if (option === '--recurring-amount') {
930
+ recurringAmount = setOnce(recurringAmount, parseNonNegativeDecimalAmount(value, option), option);
931
+ }
932
+ else {
933
+ oneOffAmount = setOnce(oneOffAmount, parseNonNegativeDecimalAmount(value, option), option);
934
+ }
935
+ }
936
+ if (!monthKey)
937
+ throw new UsageError('scenarios create requires --month <YYYY-MM>');
938
+ if (!name)
939
+ throw new UsageError('scenarios create requires --name <name>');
940
+ if (!accountRef)
941
+ throw new UsageError('scenarios create requires --account-ref <accountRef>');
942
+ if (recurringAmount === undefined && oneOffAmount === undefined) {
943
+ throw new UsageError('scenarios create requires --recurring-amount or --one-off-amount');
944
+ }
945
+ if ((recurringAmount ?? 0) === 0 && (oneOffAmount ?? 0) === 0) {
946
+ throw new UsageError('scenarios create requires at least one positive contribution');
947
+ }
948
+ return withBaseUrl({
949
+ command: 'scenarios-create',
950
+ monthKey,
951
+ name,
952
+ accountRef,
953
+ ...(recurringAmount === undefined ? {} : { recurringAmount }),
954
+ ...(oneOffAmount === undefined
955
+ ? recurringAmount === undefined ? {} : { oneOffAmount: 0 }
956
+ : { oneOffAmount }),
957
+ apply,
958
+ }, baseUrl);
959
+ }
960
+ if (subcommand === 'update') {
961
+ let monthKey;
962
+ let name;
963
+ let optionId;
964
+ let optionLabel;
965
+ let accountRef;
966
+ let recurringAmount;
967
+ let oneOffAmount;
968
+ let apply = false;
969
+ for (let index = 0; index < args.length; index += 1) {
970
+ const argument = args[index];
971
+ if (argument === '--apply') {
972
+ if (apply)
973
+ throw new UsageError('--apply may only be provided once');
974
+ apply = true;
975
+ continue;
976
+ }
977
+ if (argument === '--clear-recurring') {
978
+ if (recurringAmount !== undefined) {
979
+ throw new UsageError('--recurring-amount and --clear-recurring are mutually exclusive');
980
+ }
981
+ recurringAmount = null;
982
+ continue;
983
+ }
984
+ const [option, inlineValue] = argument.includes('=')
985
+ ? argument.split(/=(.*)/s, 2)
986
+ : [argument, undefined];
987
+ if (option !== '--month'
988
+ && option !== '--name'
989
+ && option !== '--option-id'
990
+ && option !== '--option-label'
991
+ && option !== '--account-ref'
992
+ && option !== '--recurring-amount'
993
+ && option !== '--one-off-amount') {
994
+ throw new UsageError(`Unknown scenarios update option: ${argument}`);
995
+ }
996
+ const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
997
+ if (inlineValue === undefined)
998
+ index += 1;
999
+ if (option === '--month') {
1000
+ monthKey = setOnce(monthKey, parseGoalMonthKey(value, option), option);
1001
+ }
1002
+ else if (option === '--name') {
1003
+ name = setOnce(name, parseScenarioText(value, option), option);
1004
+ }
1005
+ else if (option === '--option-id') {
1006
+ optionId = setOnce(optionId, parseScenarioText(value, option, 200), option);
1007
+ }
1008
+ else if (option === '--option-label') {
1009
+ optionLabel = setOnce(optionLabel, parseScenarioText(value, option), option);
1010
+ }
1011
+ else if (option === '--account-ref') {
1012
+ accountRef = setOnce(accountRef, parseAccountRef(value), option);
1013
+ }
1014
+ else if (option === '--recurring-amount') {
1015
+ if (recurringAmount !== undefined) {
1016
+ throw new UsageError('--recurring-amount and --clear-recurring are mutually exclusive');
1017
+ }
1018
+ recurringAmount = parseNonNegativeDecimalAmount(value, option);
1019
+ }
1020
+ else {
1021
+ oneOffAmount = setOnce(oneOffAmount, parseNonNegativeDecimalAmount(value, option), option);
1022
+ }
1023
+ }
1024
+ if (!monthKey)
1025
+ throw new UsageError('scenarios update requires --month <YYYY-MM>');
1026
+ if (optionLabel !== undefined && optionId === undefined) {
1027
+ throw new UsageError('--option-label requires --option-id');
1028
+ }
1029
+ if ((recurringAmount !== undefined || oneOffAmount !== undefined) && !accountRef) {
1030
+ throw new UsageError('scenario contribution changes require --account-ref');
1031
+ }
1032
+ if (accountRef !== undefined
1033
+ && recurringAmount === undefined
1034
+ && oneOffAmount === undefined) {
1035
+ throw new UsageError('--account-ref requires a contribution change');
1036
+ }
1037
+ if (name === undefined
1038
+ && optionLabel === undefined
1039
+ && accountRef === undefined
1040
+ && recurringAmount === undefined
1041
+ && oneOffAmount === undefined) {
1042
+ throw new UsageError('scenarios update requires at least one field to update');
1043
+ }
1044
+ return withBaseUrl({
1045
+ command: 'scenarios-update',
1046
+ monthKey,
1047
+ ...(name === undefined ? {} : { name }),
1048
+ ...(optionId === undefined ? {} : { optionId }),
1049
+ ...(optionLabel === undefined ? {} : { optionLabel }),
1050
+ ...(accountRef === undefined ? {} : { accountRef }),
1051
+ ...(recurringAmount === undefined ? {} : { recurringAmount }),
1052
+ ...(oneOffAmount === undefined ? {} : { oneOffAmount }),
1053
+ apply,
1054
+ }, baseUrl);
1055
+ }
1056
+ if (subcommand === 'activate' || subcommand === 'delete') {
1057
+ let monthKey;
1058
+ let optionId;
1059
+ let apply = false;
1060
+ for (let index = 0; index < args.length; index += 1) {
1061
+ const argument = args[index];
1062
+ if (argument === '--apply') {
1063
+ if (apply)
1064
+ throw new UsageError('--apply may only be provided once');
1065
+ apply = true;
1066
+ continue;
1067
+ }
1068
+ const [option, inlineValue] = argument.includes('=')
1069
+ ? argument.split(/=(.*)/s, 2)
1070
+ : [argument, undefined];
1071
+ if (option !== '--month' && !(subcommand === 'activate' && option === '--option-id')) {
1072
+ throw new UsageError(`Unknown scenarios ${subcommand} option: ${argument}`);
1073
+ }
1074
+ const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
1075
+ if (inlineValue === undefined)
1076
+ index += 1;
1077
+ if (option === '--month') {
1078
+ monthKey = setOnce(monthKey, parseGoalMonthKey(value, option), option);
1079
+ }
1080
+ else {
1081
+ optionId = setOnce(optionId, parseScenarioText(value, option, 200), option);
1082
+ }
1083
+ }
1084
+ if (!monthKey)
1085
+ throw new UsageError(`scenarios ${subcommand} requires --month <YYYY-MM>`);
1086
+ if (subcommand === 'activate') {
1087
+ if (!optionId)
1088
+ throw new UsageError('scenarios activate requires --option-id <id>');
1089
+ return withBaseUrl({
1090
+ command: 'scenarios-activate', monthKey, optionId, apply,
1091
+ }, baseUrl);
1092
+ }
1093
+ return withBaseUrl({ command: 'scenarios-delete', monthKey, apply }, baseUrl);
1094
+ }
1095
+ throw new UsageError(`Unknown scenarios command: ${subcommand}`);
1096
+ }
846
1097
  function parseRules(args, baseUrl) {
847
1098
  const subcommand = args.shift();
848
1099
  if (subcommand === undefined || subcommand === 'list') {
@@ -924,6 +1175,19 @@ function helpTopic(argv) {
924
1175
  return 'goals-delete';
925
1176
  return 'goals';
926
1177
  }
1178
+ if (command === 'scenarios') {
1179
+ if (subcommand === 'list')
1180
+ return 'scenarios-list';
1181
+ if (subcommand === 'create')
1182
+ return 'scenarios-create';
1183
+ if (subcommand === 'update')
1184
+ return 'scenarios-update';
1185
+ if (subcommand === 'activate')
1186
+ return 'scenarios-activate';
1187
+ if (subcommand === 'delete')
1188
+ return 'scenarios-delete';
1189
+ return 'scenarios';
1190
+ }
927
1191
  if (command === 'rules') {
928
1192
  if (subcommand === 'get')
929
1193
  return 'rules-get';
@@ -979,8 +1243,8 @@ function helpTopic(argv) {
979
1243
  return 'accounts-remove';
980
1244
  return command;
981
1245
  }
982
- if (command === 'investments')
983
- return 'investments';
1246
+ if (command === 'investments' || command === 'portfolio')
1247
+ return command;
984
1248
  return undefined;
985
1249
  }
986
1250
  export function parseArgs(argv) {
@@ -1000,6 +1264,9 @@ export function parseArgs(argv) {
1000
1264
  if (command === 'goals') {
1001
1265
  return parseGoals(args, baseUrl);
1002
1266
  }
1267
+ if (command === 'scenarios') {
1268
+ return parseScenarios(args, baseUrl);
1269
+ }
1003
1270
  if (command === 'rules') {
1004
1271
  return parseRules(args, baseUrl);
1005
1272
  }
@@ -1015,6 +1282,9 @@ export function parseArgs(argv) {
1015
1282
  if (command === 'investments') {
1016
1283
  return parseInvestments(args, baseUrl);
1017
1284
  }
1285
+ if (command === 'portfolio') {
1286
+ return parsePortfolio(args, baseUrl);
1287
+ }
1018
1288
  if (command === 'budget') {
1019
1289
  return parseBudget(args, baseUrl);
1020
1290
  }
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { ICON_KEYS } from './category-metadata.js';
6
6
  import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
7
7
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
8
8
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
9
- export const CLI_VERSION = '0.19.0';
9
+ export const CLI_VERSION = '0.21.0';
10
10
  const REQUEST_TIMEOUT_MS = 60_000;
11
11
  const MAX_CONTRACT_PDF_BYTES = 6_000_000;
12
12
  const API_ORIGIN_HELP_LINES = [
@@ -34,6 +34,7 @@ export function usageText() {
34
34
  ' sloth-agent accounts update --account-ref REF [fields] [--apply]',
35
35
  ' sloth-agent accounts remove --account-ref REF [--apply]',
36
36
  ' sloth-agent investments [--account-ref REF] [--base-url URL]',
37
+ ' sloth-agent portfolio [--view mine|partner|household] [--base-url URL]',
37
38
  ' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
38
39
  ' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
39
40
  ' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
@@ -67,6 +68,12 @@ export function usageText() {
67
68
  ' sloth-agent goals mark-spent --goal-id ID [--apply] [--base-url URL]',
68
69
  ' sloth-agent goals restore --goal-id ID [--apply] [--base-url URL]',
69
70
  ' sloth-agent goals delete --goal-id ID [--apply] [--base-url URL]',
71
+ ' sloth-agent scenarios [list] [--base-url URL]',
72
+ ' sloth-agent scenarios create --month YYYY-MM --name NAME --account-ref REF',
73
+ ' [--recurring-amount AMOUNT] [--one-off-amount AMOUNT] [--apply]',
74
+ ' sloth-agent scenarios update --month YYYY-MM [fields] [--apply]',
75
+ ' sloth-agent scenarios activate --month YYYY-MM --option-id ID [--apply]',
76
+ ' sloth-agent scenarios delete --month YYYY-MM [--apply]',
70
77
  ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
71
78
  '',
72
79
  'Help:',
@@ -378,11 +385,14 @@ export function accountsUpdateHelpText() {
378
385
  ' --balance-amount AMOUNT Balance-only account balance.',
379
386
  ' --account-type savings|investments Balance-only account type.',
380
387
  ' --goal-funding-account true|false Whether Goals may use this account.',
388
+ ' --partner-visibility private|balance|holdings',
389
+ ' What this account shares with your partner.',
381
390
  '',
382
391
  'Write behavior:',
383
392
  ' Without --apply, returns a JSON preview without credentials or a network request.',
384
393
  ' With --apply, requires agent:write on a write-enabled token and updates saved Sloth metadata.',
385
- ' Connected accounts support only --goal-funding-account.',
394
+ ' Connected accounts support --goal-funding-account and --partner-visibility.',
395
+ ' Sharing exposes planning data only. It does not change ownership or assign the account to Goals.',
386
396
  ' Manual current accounts cannot change type, balance, or Goal-funding membership.',
387
397
  ' Partner-owned shared accounts cannot be changed.',
388
398
  ' Unknown, disconnected, or inaccessible references return Account not found.',
@@ -442,6 +452,33 @@ export function investmentsHelpText() {
442
452
  ' provider-native and are not converted or guaranteed to reconcile to totals.',
443
453
  ].join('\n');
444
454
  }
455
+ export function portfolioHelpText() {
456
+ return [
457
+ 'Sloth Agent CLI — portfolio',
458
+ '',
459
+ 'Read your savings and investments from one household planning perspective.',
460
+ '',
461
+ 'Usage:',
462
+ ' sloth-agent portfolio [--view mine|partner|household] [--base-url URL]',
463
+ '',
464
+ 'Options:',
465
+ ' --view mine|partner|household Optional. Defaults to mine.',
466
+ ' --base-url URL Optional. Override the API origin.',
467
+ ' -h, --help Show this help.',
468
+ ...API_ORIGIN_HELP_LINES,
469
+ '',
470
+ 'Access:',
471
+ ' This read-only command waits up to 45 seconds for eligible linked balances to refresh.',
472
+ ' Partner shows only balances or holdings your partner explicitly shared.',
473
+ ' Household combines your accounts with those shared balances and deduplicates joint accounts.',
474
+ ' Shared data supports planning only. It does not assign partner accounts to Goals or change ownership.',
475
+ '',
476
+ 'Output:',
477
+ ' totals gives savings, investments, and tracked amounts in the viewer currency.',
478
+ ' accounts includes ownerRole, freshness, sharing level, and permitted holdings.',
479
+ ' refresh reports whether eligible linked balances refreshed or cached data was returned.',
480
+ ].join('\n');
481
+ }
445
482
  export function budgetHelpText() {
446
483
  return [
447
484
  'Sloth Agent CLI — budget',
@@ -927,6 +964,192 @@ export function goalsDeleteHelpText() {
927
964
  ' Apply mode returns deleted and deletedGoalId.',
928
965
  ].join('\n');
929
966
  }
967
+ export function scenariosHelpText() {
968
+ return [
969
+ 'Sloth Agent CLI - scenarios',
970
+ '',
971
+ 'Manage the month-anchored choices used by the Goal forecast.',
972
+ 'Each scenario contains options. Its active option controls the forecast calculation.',
973
+ '',
974
+ 'Commands:',
975
+ ' sloth-agent scenarios list List scenarios and their options.',
976
+ ' sloth-agent scenarios create Create a No/Yes scenario.',
977
+ ' sloth-agent scenarios update Change a scenario or option.',
978
+ ' sloth-agent scenarios activate Make an option active.',
979
+ ' sloth-agent scenarios delete Remove a scenario.',
980
+ '',
981
+ 'Help:',
982
+ ' Run sloth-agent scenarios <command> --help for command-specific details.',
983
+ ...API_ORIGIN_HELP_LINES,
984
+ ].join('\n');
985
+ }
986
+ export function scenariosListHelpText() {
987
+ return [
988
+ 'Sloth Agent CLI - scenarios list',
989
+ '',
990
+ 'List each scenario, its active option, and account contributions.',
991
+ '',
992
+ 'Usage:',
993
+ ' sloth-agent scenarios [list] [--base-url URL] [-h]',
994
+ '',
995
+ 'Options:',
996
+ ' --base-url URL Optional. Override the API origin.',
997
+ ' -h, --help Show this help.',
998
+ '',
999
+ 'Behavior:',
1000
+ 'This command is read-only and requires agent:read.',
1001
+ ...API_ORIGIN_HELP_LINES,
1002
+ '',
1003
+ 'Output:',
1004
+ ' JSON with currency, forecastBasis, and scenarios. Each scenario contains',
1005
+ ' activeOptionId and options with isActive and account contributions.',
1006
+ ].join('\n');
1007
+ }
1008
+ export function scenariosCreateHelpText() {
1009
+ return [
1010
+ 'Sloth Agent CLI - scenarios create',
1011
+ '',
1012
+ 'Create a month-anchored choice and recalculate the Goal roadmap.',
1013
+ '',
1014
+ 'Usage:',
1015
+ ' sloth-agent scenarios create --month YYYY-MM --name NAME --account-ref REF',
1016
+ ' [--recurring-amount AMOUNT] [--one-off-amount AMOUNT] [--apply]',
1017
+ ' [--base-url URL] [-h]',
1018
+ '',
1019
+ 'Required:',
1020
+ ' --month YYYY-MM Month when this scenario begins.',
1021
+ ' --name NAME Question shown for the scenario, up to 60 characters.',
1022
+ ' --account-ref REF Exact accountRef from sloth-agent accounts.',
1023
+ '',
1024
+ 'Contribution: provide at least one:',
1025
+ ' --recurring-amount AMOUNT Optional monthly contribution in the budget currency.',
1026
+ ' --one-off-amount AMOUNT Optional contribution for this month.',
1027
+ ' AMOUNT accepts zero or a positive decimal with at most two decimal places.',
1028
+ ' At least one supplied amount must be positive.',
1029
+ '',
1030
+ 'Options:',
1031
+ ' --apply Optional. Save the scenario; otherwise preview it.',
1032
+ ' --base-url URL Optional. Override the API origin.',
1033
+ ' -h, --help Show this help.',
1034
+ '',
1035
+ 'Provide at least one positive contribution. A recurring contribution continues',
1036
+ 'until a later active scenario changes it. Creation adds No and Yes options and',
1037
+ 'activates Yes. It records a forecast assumption and does not move money.',
1038
+ '',
1039
+ 'Write behavior:',
1040
+ ' Without --apply, Sloth authenticates, calculates the result, and performs zero writes.',
1041
+ ' With --apply, Sloth saves the scenario using a write-enabled token with Allow changes.',
1042
+ ...API_ORIGIN_HELP_LINES,
1043
+ '',
1044
+ 'Output:',
1045
+ ' JSON with changed, forecastBasis, the proposed or saved scenario, and',
1046
+ ' recalculated Goals. Preview and apply use the same output contract.',
1047
+ '',
1048
+ 'Example:',
1049
+ ' sloth-agent scenarios create --month 2026-09 \\',
1050
+ ' --name "Deposit £100 into the shopping pot each month?" \\',
1051
+ ' --account-ref PASTE_THE_EXACT_ACCOUNT_REF_HERE --recurring-amount 100',
1052
+ ].join('\n');
1053
+ }
1054
+ export function scenariosUpdateHelpText() {
1055
+ return [
1056
+ 'Sloth Agent CLI - scenarios update',
1057
+ '',
1058
+ 'Change a scenario, one option, or an account contribution.',
1059
+ '',
1060
+ 'Usage:',
1061
+ ' sloth-agent scenarios update --month YYYY-MM [fields] [--apply]',
1062
+ ' [--base-url URL] [-h]',
1063
+ '',
1064
+ 'Required:',
1065
+ ' --month YYYY-MM Scenario month.',
1066
+ '',
1067
+ 'Fields:',
1068
+ ' --name NAME Rename the scenario, up to 60 characters.',
1069
+ ' --option-id ID Select an option ID, up to 200 characters.',
1070
+ ' --option-label LABEL Rename it, up to 60 characters; requires --option-id.',
1071
+ ' --account-ref REF Account for contribution changes.',
1072
+ ' --recurring-amount AMOUNT Set a monthly contribution; cannot be used',
1073
+ ' with --clear-recurring.',
1074
+ ' --clear-recurring Inherit the earlier recurring amount.',
1075
+ ' --one-off-amount AMOUNT Set this month\'s one-off contribution.',
1076
+ ' Amounts accept zero or a positive decimal with at most two decimal places.',
1077
+ ' Contribution fields require --account-ref, and --account-ref requires one',
1078
+ ' of those fields. Provide at least one field that changes the scenario.',
1079
+ '',
1080
+ 'Options:',
1081
+ ' --apply Optional. Save the change; otherwise preview it.',
1082
+ ' --base-url URL Optional. Override the API origin.',
1083
+ ' -h, --help Show this help.',
1084
+ '',
1085
+ 'Contribution changes use the active option when --option-id is omitted.',
1086
+ 'For recurring contributions, zero explicitly stops the earlier recurring amount.',
1087
+ '--clear-recurring removes this override so the earlier recurring amount continues.',
1088
+ '',
1089
+ 'Write behavior:',
1090
+ ' Without --apply, Sloth authenticates, calculates the result, and performs zero writes.',
1091
+ ' With --apply, Sloth saves the change using a write-enabled token with Allow changes.',
1092
+ ...API_ORIGIN_HELP_LINES,
1093
+ '',
1094
+ 'Output:',
1095
+ ' JSON with changed, forecastBasis, the proposed or saved scenario, and',
1096
+ ' recalculated Goals. Preview and apply use the same output contract.',
1097
+ ].join('\n');
1098
+ }
1099
+ export function scenariosActivateHelpText() {
1100
+ return [
1101
+ 'Sloth Agent CLI - scenarios activate',
1102
+ '',
1103
+ 'Select the option that controls the forecast and recalculates Goals.',
1104
+ '',
1105
+ 'Usage:',
1106
+ ' sloth-agent scenarios activate --month YYYY-MM --option-id ID [--apply]',
1107
+ ' [--base-url URL] [-h]',
1108
+ '',
1109
+ 'Required:',
1110
+ ' --month YYYY-MM Scenario month.',
1111
+ ' --option-id ID Exact option ID from scenarios list, up to 200 characters.',
1112
+ '',
1113
+ 'Options:',
1114
+ ' --apply Optional. Save the active option; otherwise preview it.',
1115
+ ' --base-url URL Optional. Override the API origin.',
1116
+ ' -h, --help Show this help.',
1117
+ '',
1118
+ 'Write behavior:',
1119
+ 'Without --apply, Sloth calculates the result and performs zero writes.',
1120
+ 'With --apply, Sloth saves the active option using a write-enabled token with Allow changes.',
1121
+ ...API_ORIGIN_HELP_LINES,
1122
+ '',
1123
+ 'Output:',
1124
+ ' JSON with changed, forecastBasis, the selected scenario, and recalculated Goals.',
1125
+ ].join('\n');
1126
+ }
1127
+ export function scenariosDeleteHelpText() {
1128
+ return [
1129
+ 'Sloth Agent CLI - scenarios delete',
1130
+ '',
1131
+ 'Remove one scenario. Sloth recalculates Goals without it.',
1132
+ '',
1133
+ 'Usage:',
1134
+ ' sloth-agent scenarios delete --month YYYY-MM [--apply] [--base-url URL] [-h]',
1135
+ '',
1136
+ 'Required:',
1137
+ ' --month YYYY-MM Scenario month.',
1138
+ '',
1139
+ 'Options:',
1140
+ ' --apply Optional. Remove the scenario; otherwise preview deletion.',
1141
+ ' --base-url URL Optional. Override the API origin.',
1142
+ ' -h, --help Show this help.',
1143
+ '',
1144
+ 'Write behavior:',
1145
+ 'Without --apply, Sloth calculates the result and performs zero writes.',
1146
+ 'With --apply, Sloth removes the scenario using a write-enabled token with Allow changes.',
1147
+ ...API_ORIGIN_HELP_LINES,
1148
+ '',
1149
+ 'Output:',
1150
+ ' JSON with changed, forecastBasis, deletedMonthKey, and recalculated Goals.',
1151
+ ].join('\n');
1152
+ }
930
1153
  export function askPartnerHelpText() {
931
1154
  return [
932
1155
  'Sloth Agent CLI — ask-partner',
@@ -1173,6 +1396,7 @@ export function commandHelpText(topic) {
1173
1396
  'accounts-update': accountsUpdateHelpText,
1174
1397
  'accounts-remove': accountsRemoveHelpText,
1175
1398
  investments: investmentsHelpText,
1399
+ portfolio: portfolioHelpText,
1176
1400
  budget: budgetHelpText,
1177
1401
  'budget-status': budgetStatusHelpText,
1178
1402
  'budget-move': budgetMoveHelpText,
@@ -1202,6 +1426,12 @@ export function commandHelpText(topic) {
1202
1426
  'goals-mark-spent': goalsMarkSpentHelpText,
1203
1427
  'goals-restore': goalsRestoreHelpText,
1204
1428
  'goals-delete': goalsDeleteHelpText,
1429
+ scenarios: scenariosHelpText,
1430
+ 'scenarios-list': scenariosListHelpText,
1431
+ 'scenarios-create': scenariosCreateHelpText,
1432
+ 'scenarios-update': scenariosUpdateHelpText,
1433
+ 'scenarios-activate': scenariosActivateHelpText,
1434
+ 'scenarios-delete': scenariosDeleteHelpText,
1205
1435
  'ask-partner': askPartnerHelpText,
1206
1436
  };
1207
1437
  return helpByTopic[topic]();
@@ -1524,6 +1754,77 @@ function hasFailures(value) {
1524
1754
  return false;
1525
1755
  return Array.isArray(value.failed) && value.failed.length > 0;
1526
1756
  }
1757
+ function scenarioRequestDescriptor(parsed, baseUrl) {
1758
+ const previewEndpoint = `${baseUrl}/api/agent/v1/scenarios/preview`;
1759
+ switch (parsed.command) {
1760
+ case 'scenarios-create': {
1761
+ const scenario = {
1762
+ monthKey: parsed.monthKey,
1763
+ name: parsed.name,
1764
+ accountRef: parsed.accountRef,
1765
+ ...(parsed.recurringAmount === undefined
1766
+ ? {}
1767
+ : { recurringAmount: parsed.recurringAmount }),
1768
+ ...(parsed.oneOffAmount === undefined
1769
+ ? {}
1770
+ : { oneOffAmount: parsed.oneOffAmount }),
1771
+ };
1772
+ return parsed.apply
1773
+ ? { endpoint: `${baseUrl}/api/agent/v1/scenarios`, method: 'POST', body: scenario }
1774
+ : {
1775
+ endpoint: previewEndpoint,
1776
+ method: 'POST',
1777
+ body: { action: 'create', scenario },
1778
+ };
1779
+ }
1780
+ case 'scenarios-update': {
1781
+ const updates = {
1782
+ ...(parsed.name === undefined ? {} : { name: parsed.name }),
1783
+ ...(parsed.optionId === undefined ? {} : { optionId: parsed.optionId }),
1784
+ ...(parsed.optionLabel === undefined ? {} : { optionLabel: parsed.optionLabel }),
1785
+ ...(parsed.accountRef === undefined ? {} : { accountRef: parsed.accountRef }),
1786
+ ...(parsed.recurringAmount === undefined
1787
+ ? {}
1788
+ : { recurringAmount: parsed.recurringAmount }),
1789
+ ...(parsed.oneOffAmount === undefined ? {} : { oneOffAmount: parsed.oneOffAmount }),
1790
+ };
1791
+ return parsed.apply
1792
+ ? {
1793
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}`,
1794
+ method: 'PATCH',
1795
+ body: updates,
1796
+ }
1797
+ : {
1798
+ endpoint: previewEndpoint,
1799
+ method: 'POST',
1800
+ body: { action: 'update', monthKey: parsed.monthKey, updates },
1801
+ };
1802
+ }
1803
+ case 'scenarios-activate':
1804
+ return parsed.apply
1805
+ ? {
1806
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}/activate`,
1807
+ method: 'POST',
1808
+ body: { optionId: parsed.optionId },
1809
+ }
1810
+ : {
1811
+ endpoint: previewEndpoint,
1812
+ method: 'POST',
1813
+ body: { action: 'activate', monthKey: parsed.monthKey, optionId: parsed.optionId },
1814
+ };
1815
+ case 'scenarios-delete':
1816
+ return parsed.apply
1817
+ ? {
1818
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}`,
1819
+ method: 'DELETE',
1820
+ }
1821
+ : {
1822
+ endpoint: previewEndpoint,
1823
+ method: 'POST',
1824
+ body: { action: 'delete', monthKey: parsed.monthKey },
1825
+ };
1826
+ }
1827
+ }
1527
1828
  export async function runCli(argv = process.argv.slice(2), options = {}) {
1528
1829
  const environment = options.env ?? process.env;
1529
1830
  const fetchImplementation = options.fetch ?? globalThis.fetch;
@@ -1969,6 +2270,31 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1969
2270
  writeJson(writeStdout, data);
1970
2271
  return 0;
1971
2272
  }
2273
+ if (parsed.command === 'scenarios-list') {
2274
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/scenarios`, {
2275
+ method: 'GET',
2276
+ headers,
2277
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2278
+ });
2279
+ writeJson(writeStdout, parseApiResponse('scenarios-list', await parseHttpResponse(response, token)));
2280
+ return 0;
2281
+ }
2282
+ if (parsed.command === 'scenarios-create'
2283
+ || parsed.command === 'scenarios-update'
2284
+ || parsed.command === 'scenarios-activate'
2285
+ || parsed.command === 'scenarios-delete') {
2286
+ const request = scenarioRequestDescriptor(parsed, baseUrl);
2287
+ const response = await fetchImplementation(request.endpoint, {
2288
+ method: request.method,
2289
+ headers: request.body === undefined
2290
+ ? headers
2291
+ : { ...headers, 'Content-Type': 'application/json' },
2292
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
2293
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2294
+ });
2295
+ writeJson(writeStdout, parseApiResponse('scenarios-mutation', await parseHttpResponse(response, token)));
2296
+ return 0;
2297
+ }
1972
2298
  if (parsed.command === 'assign') {
1973
2299
  const payload = assignmentPayload;
1974
2300
  const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
@@ -2063,19 +2389,21 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2063
2389
  }
2064
2390
  const path = parsed.command === 'accounts'
2065
2391
  ? '/api/agent/v1/accounts'
2066
- : parsed.command === 'investments'
2067
- ? `/api/agent/v1/investments${parsed.accountRef
2068
- ? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
2069
- : ''}`
2070
- : parsed.command === 'categories'
2071
- ? '/api/agent/v1/categories'
2072
- : `/api/agent/v1/transactions${(() => {
2073
- const query = buildTransactionsQuery(parsed.filters);
2074
- return query ? `?${query}` : '';
2075
- })()}`;
2392
+ : parsed.command === 'portfolio'
2393
+ ? `/api/agent/v1/portfolio?${new URLSearchParams({ view: parsed.view }).toString()}`
2394
+ : parsed.command === 'investments'
2395
+ ? `/api/agent/v1/investments${parsed.accountRef
2396
+ ? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
2397
+ : ''}`
2398
+ : parsed.command === 'categories'
2399
+ ? '/api/agent/v1/categories'
2400
+ : `/api/agent/v1/transactions${(() => {
2401
+ const query = buildTransactionsQuery(parsed.filters);
2402
+ return query ? `?${query}` : '';
2403
+ })()}`;
2076
2404
  const response = await fetchImplementation(`${baseUrl}${path}`, {
2077
2405
  method: 'GET',
2078
- headers: parsed.command === 'transactions'
2406
+ headers: parsed.command === 'transactions' || parsed.command === 'portfolio'
2079
2407
  ? { ...headers, Prefer: 'wait=45' }
2080
2408
  : headers,
2081
2409
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
package/dist/contracts.js CHANGED
@@ -861,6 +861,7 @@ function isAccount(value) {
861
861
  'lastBalanceUpdatedAt',
862
862
  'connectionState',
863
863
  'isGoalFundingAccount',
864
+ 'partnerVisibility',
864
865
  ])
865
866
  && isAccountRef(value.accountRef)
866
867
  && isNullableNonEmptyString(value.accountName)
@@ -879,7 +880,10 @@ function isAccount(value) {
879
880
  || value.connectionState === 'expired'
880
881
  || value.connectionState === 'manual'
881
882
  || value.connectionState === 'unknown')
882
- && typeof value.isGoalFundingAccount === 'boolean');
883
+ && typeof value.isGoalFundingAccount === 'boolean'
884
+ && (value.partnerVisibility === 'private'
885
+ || value.partnerVisibility === 'balance'
886
+ || value.partnerVisibility === 'holdings'));
883
887
  }
884
888
  function isAccountsResponse(value) {
885
889
  return (isObject(value)
@@ -945,6 +949,55 @@ function isInvestmentsResponse(value) {
945
949
  && holdings.every(isInvestmentHolding));
946
950
  }));
947
951
  }
952
+ function isPortfolioAccount(value) {
953
+ return isObject(value)
954
+ && hasOnlyFields(value, [
955
+ 'accountRef', 'ownerRole', 'accountName', 'institutionName', 'accountType',
956
+ 'ownership', 'balanceAmount', 'currency', 'source', 'lastBalanceUpdatedAt',
957
+ 'connectionState', 'partnerVisibility', 'isGoalFundingAccount', 'holdings',
958
+ ])
959
+ && isAccountRef(value.accountRef)
960
+ && (value.ownerRole === 'you' || value.ownerRole === 'partner')
961
+ && isNullableNonEmptyString(value.accountName)
962
+ && isNullableNonEmptyString(value.institutionName)
963
+ && (value.accountType === 'savings' || value.accountType === 'investments')
964
+ && (value.ownership === 'personal' || value.ownership === 'joint')
965
+ && (value.balanceAmount === null || (typeof value.balanceAmount === 'number' && Number.isFinite(value.balanceAmount)))
966
+ && isCurrency(value.currency)
967
+ && (value.source === 'connected' || value.source === 'manual')
968
+ && (value.lastBalanceUpdatedAt === null || isIsoDateTime(value.lastBalanceUpdatedAt))
969
+ && ['active', 'expired', 'manual', 'unknown'].includes(String(value.connectionState))
970
+ && ['private', 'balance', 'holdings'].includes(String(value.partnerVisibility))
971
+ && (value.isGoalFundingAccount === null || typeof value.isGoalFundingAccount === 'boolean')
972
+ && Array.isArray(value.holdings)
973
+ && value.holdings.every(isInvestmentHolding);
974
+ }
975
+ function isPortfolioResponse(value) {
976
+ if (!isObject(value) || !hasOnlyFields(value, [
977
+ 'asOf', 'currency', 'view', 'hasPartner', 'totals',
978
+ 'excludedCurrencyAccountCount', 'accounts', 'refresh',
979
+ ]))
980
+ return false;
981
+ const totals = value.totals;
982
+ const refresh = value.refresh;
983
+ return isIsoDateTime(value.asOf)
984
+ && isCurrency(value.currency)
985
+ && ['mine', 'partner', 'household'].includes(String(value.view))
986
+ && typeof value.hasPartner === 'boolean'
987
+ && isObject(totals)
988
+ && hasOnlyFields(totals, ['savingsAmount', 'investmentsAmount', 'trackedAmount'])
989
+ && ['savingsAmount', 'investmentsAmount', 'trackedAmount'].every(field => (typeof totals[field] === 'number' && Number.isFinite(totals[field])))
990
+ && Number.isSafeInteger(value.excludedCurrencyAccountCount)
991
+ && Number(value.excludedCurrencyAccountCount) >= 0
992
+ && Array.isArray(value.accounts)
993
+ && value.accounts.every(isPortfolioAccount)
994
+ && isObject(refresh)
995
+ && hasOnlyFields(refresh, ['status', 'reason', 'utcDate'])
996
+ && ['skipped', 'completed', 'in_progress', 'partial', 'failed'].includes(String(refresh.status))
997
+ && typeof refresh.reason === 'string'
998
+ && refresh.reason.length > 0
999
+ && isIsoDate(refresh.utcDate);
1000
+ }
948
1001
  function isForecastBasis(value) {
949
1002
  return isObject(value)
950
1003
  && hasOnlyFields(value, [
@@ -1001,6 +1054,101 @@ function isGoalDeleteResponse(value) {
1001
1054
  && typeof value.deletedGoalId === 'string'
1002
1055
  && value.deletedGoalId.trim().length > 0);
1003
1056
  }
1057
+ function isScenarioAmount(value) {
1058
+ return typeof value === 'number'
1059
+ && Number.isFinite(value)
1060
+ && value >= 0
1061
+ && Math.abs(value * 100 - Math.round(value * 100)) < Number.EPSILON * 100;
1062
+ }
1063
+ function isScenarioContribution(value) {
1064
+ return isObject(value)
1065
+ && hasOnlyFields(value, [
1066
+ 'accountRef',
1067
+ 'accountLabel',
1068
+ 'accountType',
1069
+ 'recurringAmount',
1070
+ 'oneOffAmount',
1071
+ ])
1072
+ && isAccountRef(value.accountRef)
1073
+ && typeof value.accountLabel === 'string'
1074
+ && value.accountLabel.trim().length > 0
1075
+ && (value.accountType === 'current'
1076
+ || value.accountType === 'savings'
1077
+ || value.accountType === 'investments')
1078
+ && (value.recurringAmount === null
1079
+ || isScenarioAmount(value.recurringAmount))
1080
+ && isScenarioAmount(value.oneOffAmount);
1081
+ }
1082
+ function isScenarioOption(value) {
1083
+ return isObject(value)
1084
+ && hasOnlyFields(value, ['id', 'label', 'isActive', 'contributions'])
1085
+ && typeof value.id === 'string'
1086
+ && value.id.trim().length > 0
1087
+ && value.id === value.id.trim()
1088
+ && value.id.length <= 200
1089
+ && typeof value.label === 'string'
1090
+ && value.label.trim().length > 0
1091
+ && value.label === value.label.trim()
1092
+ && value.label.length <= 60
1093
+ && typeof value.isActive === 'boolean'
1094
+ && Array.isArray(value.contributions)
1095
+ && value.contributions.every(isScenarioContribution);
1096
+ }
1097
+ function hasConsistentScenarioOptions(options, activeOptionId) {
1098
+ const parsedOptions = options.filter(isObject);
1099
+ if (parsedOptions.length !== options.length)
1100
+ return false;
1101
+ const optionIds = parsedOptions.map(option => String(option.id));
1102
+ const activeOptions = parsedOptions.filter(option => option.isActive === true);
1103
+ return new Set(optionIds).size === optionIds.length
1104
+ && activeOptions.length === 1
1105
+ && activeOptions[0]?.id === activeOptionId;
1106
+ }
1107
+ function isScenario(value) {
1108
+ return isObject(value)
1109
+ && hasOnlyFields(value, ['monthKey', 'name', 'activeOptionId', 'options'])
1110
+ && isMonthKeyOrNull(value.monthKey)
1111
+ && value.monthKey !== null
1112
+ && (value.name === null
1113
+ || (typeof value.name === 'string'
1114
+ && value.name.trim().length > 0
1115
+ && value.name === value.name.trim()
1116
+ && value.name.length <= 60))
1117
+ && typeof value.activeOptionId === 'string'
1118
+ && value.activeOptionId.trim().length > 0
1119
+ && value.activeOptionId === value.activeOptionId.trim()
1120
+ && value.activeOptionId.length <= 200
1121
+ && Array.isArray(value.options)
1122
+ && value.options.length > 0
1123
+ && value.options.every(isScenarioOption)
1124
+ && hasConsistentScenarioOptions(value.options, value.activeOptionId);
1125
+ }
1126
+ function isScenariosResponse(value) {
1127
+ return isObject(value)
1128
+ && hasOnlyFields(value, ['currency', 'forecastBasis', 'scenarios'])
1129
+ && isCurrency(value.currency)
1130
+ && isForecastBasis(value.forecastBasis)
1131
+ && Array.isArray(value.scenarios)
1132
+ && value.scenarios.every(isScenario);
1133
+ }
1134
+ function isScenarioMutationResponse(value) {
1135
+ return isObject(value)
1136
+ && hasOnlyFields(value, [
1137
+ 'currency',
1138
+ 'changed',
1139
+ 'forecastBasis',
1140
+ 'scenario',
1141
+ 'deletedMonthKey',
1142
+ 'goals',
1143
+ ])
1144
+ && isCurrency(value.currency)
1145
+ && typeof value.changed === 'boolean'
1146
+ && isForecastBasis(value.forecastBasis)
1147
+ && (value.scenario === null || isScenario(value.scenario))
1148
+ && isMonthKeyOrNull(value.deletedMonthKey)
1149
+ && Array.isArray(value.goals)
1150
+ && value.goals.every(isGoal);
1151
+ }
1004
1152
  function isReceiptConfirmation(value) {
1005
1153
  try {
1006
1154
  validateReceiptConfirmation(value);
@@ -1059,47 +1207,53 @@ export function parseApiResponse(command, value) {
1059
1207
  ? isAccountRemovalResponse(value)
1060
1208
  : command === 'investments'
1061
1209
  ? isInvestmentsResponse(value)
1062
- : command === 'budget' || command === 'budget-update'
1063
- ? isBudgetResponse(value)
1064
- : command === 'budget-status'
1065
- ? isBudgetActivityStatusResponse(value)
1066
- : command === 'budget-move'
1067
- ? isBudgetMovementResponse(value)
1068
- : command === 'categories'
1069
- ? isCategoryResponse(value)
1070
- : command === 'categories-create' || command === 'categories-rename'
1071
- ? isCategoryMutationResponse(value)
1072
- : command === 'line-items-create' || command === 'line-items-rename'
1073
- ? isLineItemMutationResponse(value)
1074
- : command === 'transactions'
1075
- ? isTransactionsResponse(value)
1076
- : command === 'rules-list'
1077
- ? isNotificationRuleListResponse(value)
1078
- : command === 'rules-get' || command === 'rules-set'
1079
- ? isNotificationRuleResponse(value)
1080
- : command === 'rules-delete'
1081
- ? isNotificationRuleDeleteResponse(value)
1082
- : command === 'rules-scan-contract'
1083
- ? isRenewalExtractionResponse(value)
1084
- : command === 'receipts-extract'
1085
- ? isReceiptExtractResponse(value)
1086
- : command === 'receipts-get'
1087
- ? isReceiptLookupResponse(value)
1088
- : command === 'receipts-attach'
1089
- ? isReceiptMutationResponse(value)
1090
- : command === 'receipts-remove'
1091
- ? isReceiptDeleteResponse(value)
1092
- : command === 'assign'
1093
- ? isAssignmentResponse(value)
1094
- : command === 'ask-partner'
1095
- ? isPartnerResponse(value)
1096
- : command === 'goals-list'
1097
- ? isGoalsResponse(value)
1098
- : command === 'goals-preview'
1099
- ? isGoalPreviewResponse(value)
1100
- : command === 'goals-delete'
1101
- ? isGoalDeleteResponse(value)
1102
- : isGoalMutationResponse(value);
1210
+ : command === 'portfolio'
1211
+ ? isPortfolioResponse(value)
1212
+ : command === 'budget' || command === 'budget-update'
1213
+ ? isBudgetResponse(value)
1214
+ : command === 'budget-status'
1215
+ ? isBudgetActivityStatusResponse(value)
1216
+ : command === 'budget-move'
1217
+ ? isBudgetMovementResponse(value)
1218
+ : command === 'categories'
1219
+ ? isCategoryResponse(value)
1220
+ : command === 'categories-create' || command === 'categories-rename'
1221
+ ? isCategoryMutationResponse(value)
1222
+ : command === 'line-items-create' || command === 'line-items-rename'
1223
+ ? isLineItemMutationResponse(value)
1224
+ : command === 'transactions'
1225
+ ? isTransactionsResponse(value)
1226
+ : command === 'rules-list'
1227
+ ? isNotificationRuleListResponse(value)
1228
+ : command === 'rules-get' || command === 'rules-set'
1229
+ ? isNotificationRuleResponse(value)
1230
+ : command === 'rules-delete'
1231
+ ? isNotificationRuleDeleteResponse(value)
1232
+ : command === 'rules-scan-contract'
1233
+ ? isRenewalExtractionResponse(value)
1234
+ : command === 'receipts-extract'
1235
+ ? isReceiptExtractResponse(value)
1236
+ : command === 'receipts-get'
1237
+ ? isReceiptLookupResponse(value)
1238
+ : command === 'receipts-attach'
1239
+ ? isReceiptMutationResponse(value)
1240
+ : command === 'receipts-remove'
1241
+ ? isReceiptDeleteResponse(value)
1242
+ : command === 'assign'
1243
+ ? isAssignmentResponse(value)
1244
+ : command === 'ask-partner'
1245
+ ? isPartnerResponse(value)
1246
+ : command === 'goals-list'
1247
+ ? isGoalsResponse(value)
1248
+ : command === 'scenarios-list'
1249
+ ? isScenariosResponse(value)
1250
+ : command === 'scenarios-mutation'
1251
+ ? isScenarioMutationResponse(value)
1252
+ : command === 'goals-preview'
1253
+ ? isGoalPreviewResponse(value)
1254
+ : command === 'goals-delete'
1255
+ ? isGoalDeleteResponse(value)
1256
+ : isGoalMutationResponse(value);
1103
1257
  if (!valid) {
1104
1258
  const label = command === 'assign' ? 'assignment' : command;
1105
1259
  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.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {