@slothmoney/agent-cli 0.20.0 → 0.21.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1 - 2026-08-29
4
+
5
+ - Clarify in portfolio, current budget status, and transaction help that a
6
+ completed eligible refresh updates the configured Budget balance audit.
7
+ - Explain that a same-day cached read reuses fresh bank data without adding
8
+ another audit checkpoint.
9
+
10
+ ## 0.21.0 - 2026-08-28
11
+
12
+ - Add `scenarios` commands to list, create, update, activate, and delete the
13
+ month-anchored choices used by Goal forecasts.
14
+ - Preview every scenario change against Sloth's canonical planner with zero
15
+ writes; `--apply` remains the explicit write boundary.
16
+ - Create the common No/Yes choice directly, support recurring and one-off
17
+ account contributions, and return the recalculated Goal roadmap.
18
+
3
19
  ## 0.20.0 - 2026-08-27
4
20
 
5
21
  - Add `portfolio` views for personal, partner-shared, and combined household
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect personal and household 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.1 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -25,9 +25,9 @@ Create a personal access token in Sloth Money under
25
25
  where the CLI runs.
26
26
 
27
27
  New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`, `portfolio`,
28
- `budget`, `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
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
@@ -122,6 +122,9 @@ sloth-agent goals create --help
122
122
  sloth-agent goals update --help
123
123
  sloth-agent goals mark-spent --help
124
124
  sloth-agent goals restore --help
125
+ sloth-agent scenarios create --help
126
+ sloth-agent scenarios update --help
127
+ sloth-agent scenarios activate --help
125
128
  sloth-agent ask-partner --help
126
129
  ```
127
130
 
@@ -446,6 +449,10 @@ nonnegative `moneyInPence` and `moneyOutPence` plus their difference as
446
449
  custom categories are normal rows. A transaction with no category at all is
447
450
  reported separately under `activity.uncategorized`.
448
451
 
452
+ A completed current-period refresh updates the Budget balance audit for any
453
+ configured backing accounts. A same-day cached read does not add another audit
454
+ checkpoint.
455
+
449
456
  `budget` contains assigned, spent, and available category amounts when a
450
457
  trustworthy period plan exists. It is `null` when it does not; activity still
451
458
  returns. The response includes only the period budget currency and silently
@@ -645,6 +652,9 @@ planning only. It does not change account ownership, transaction access, Goal
645
652
  funding, or who can move money. Totals use the viewer's budget currency and
646
653
  exclude other native currencies without converting them.
647
654
 
655
+ A completed balance refresh updates the Budget balance audit for any configured
656
+ backing accounts. A same-day cached read does not add another audit checkpoint.
657
+
648
658
  Archive an owned manual account. The account disappears from active Sloth
649
659
  surfaces, but its underlying records are retained. Repeating an applied removal
650
660
  is safe and returns `changed: false`.
@@ -748,6 +758,52 @@ one-based `priority`; `1` is highest. Moving one goal automatically shifts the
748
758
  goals between its old and new positions. Sloth recalculates the active-scenario
749
759
  roadmap before every applied Goal mutation and returns the updated forecast.
750
760
 
761
+ List the scenarios that supply assumptions to that roadmap:
762
+
763
+ ```bash
764
+ sloth-agent scenarios
765
+ ```
766
+
767
+ Create a monthly contribution choice. Preview is the default and performs zero
768
+ writes; add `--apply` after reviewing the returned scenario and recalculated
769
+ Goals:
770
+
771
+ ```bash
772
+ sloth-agent scenarios create \
773
+ --month 2026-09 \
774
+ --name "Deposit £100 into the shopping pot each month?" \
775
+ --account-ref sloth_account_v1_... \
776
+ --recurring-amount 100
777
+ ```
778
+
779
+ Creation adds No and Yes options and activates Yes. The recurring contribution
780
+ continues until a later active scenario changes it. A one-off amount applies
781
+ only in the scenario month. Scenarios alter the forecast; they do not move
782
+ money.
783
+
784
+ Use stable IDs from `scenarios` output to edit or select an option:
785
+
786
+ ```bash
787
+ sloth-agent scenarios update \
788
+ --month 2026-09 \
789
+ --option-id yes \
790
+ --account-ref sloth_account_v1_... \
791
+ --recurring-amount 125 \
792
+ --apply
793
+
794
+ sloth-agent scenarios activate \
795
+ --month 2026-09 \
796
+ --option-id no \
797
+ --apply
798
+
799
+ sloth-agent scenarios delete --month 2026-09 --apply
800
+ ```
801
+
802
+ For recurring contributions, `--recurring-amount 0` explicitly stops the
803
+ earlier amount. `--clear-recurring` removes this month's override, so the
804
+ earlier recurring amount continues. Contribution updates use the active option
805
+ when `--option-id` is omitted.
806
+
751
807
  Read uncategorised contributions to the joint budget:
752
808
 
753
809
  ```bash
@@ -770,6 +826,10 @@ a structured `refresh` object:
770
826
  }
771
827
  ```
772
828
 
829
+ A completed refresh also updates the Budget balance audit for any configured
830
+ backing accounts. A same-day cached read reuses the existing data and does not
831
+ add another audit checkpoint.
832
+
773
833
  Re-run the transaction query later to observe the completed refresh. A partial
774
834
  account failure remains eligible for an automatic retry.
775
835
 
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('.');
@@ -864,6 +874,226 @@ function parseGoals(args, baseUrl) {
864
874
  }
865
875
  throw new UsageError(`Unknown goals command: ${subcommand}`);
866
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
+ }
867
1097
  function parseRules(args, baseUrl) {
868
1098
  const subcommand = args.shift();
869
1099
  if (subcommand === undefined || subcommand === 'list') {
@@ -945,6 +1175,19 @@ function helpTopic(argv) {
945
1175
  return 'goals-delete';
946
1176
  return 'goals';
947
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
+ }
948
1191
  if (command === 'rules') {
949
1192
  if (subcommand === 'get')
950
1193
  return 'rules-get';
@@ -1021,6 +1264,9 @@ export function parseArgs(argv) {
1021
1264
  if (command === 'goals') {
1022
1265
  return parseGoals(args, baseUrl);
1023
1266
  }
1267
+ if (command === 'scenarios') {
1268
+ return parseScenarios(args, baseUrl);
1269
+ }
1024
1270
  if (command === 'rules') {
1025
1271
  return parseRules(args, baseUrl);
1026
1272
  }
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.20.0';
9
+ export const CLI_VERSION = '0.21.1';
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 = [
@@ -68,6 +68,12 @@ export function usageText() {
68
68
  ' sloth-agent goals mark-spent --goal-id ID [--apply] [--base-url URL]',
69
69
  ' sloth-agent goals restore --goal-id ID [--apply] [--base-url URL]',
70
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]',
71
77
  ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
72
78
  '',
73
79
  'Help:',
@@ -463,6 +469,8 @@ export function portfolioHelpText() {
463
469
  '',
464
470
  'Access:',
465
471
  ' This read-only command waits up to 45 seconds for eligible linked balances to refresh.',
472
+ ' A completed refresh updates the Budget balance audit for configured backing accounts.',
473
+ ' A same-day cached read does not add another audit checkpoint.',
466
474
  ' Partner shows only balances or holdings your partner explicitly shared.',
467
475
  ' Household combines your accounts with those shared balances and deduplicates joint accounts.',
468
476
  ' Shared data supports planning only. It does not assign partner accounts to Goals or change ownership.',
@@ -527,6 +535,8 @@ export function budgetStatusHelpText() {
527
535
  'Access and freshness:',
528
536
  ' This command is read-only, requires agent:read, and never changes the budget.',
529
537
  ' Current periods use the normal transaction refresh policy; historical periods are cache-only.',
538
+ ' A completed refresh updates the Budget balance audit for configured backing accounts.',
539
+ ' A same-day cached read does not add another audit checkpoint.',
530
540
  ' A historical response returns refresh as null.',
531
541
  '',
532
542
  'Output:',
@@ -648,6 +658,8 @@ export function transactionsHelpText() {
648
658
  ' --end-date must not be before --start-date.',
649
659
  ' The first transaction read each UTC day may refresh linked bank data.',
650
660
  ' Refresh remotely persists booked transactions and account balances.',
661
+ ' A completed refresh updates the Budget balance audit for configured backing accounts.',
662
+ ' A same-day cached read does not add another audit checkpoint.',
651
663
  ' The command waits up to 45 seconds, then returns cached data if refresh continues.',
652
664
  '',
653
665
  'Output:',
@@ -958,6 +970,192 @@ export function goalsDeleteHelpText() {
958
970
  ' Apply mode returns deleted and deletedGoalId.',
959
971
  ].join('\n');
960
972
  }
973
+ export function scenariosHelpText() {
974
+ return [
975
+ 'Sloth Agent CLI - scenarios',
976
+ '',
977
+ 'Manage the month-anchored choices used by the Goal forecast.',
978
+ 'Each scenario contains options. Its active option controls the forecast calculation.',
979
+ '',
980
+ 'Commands:',
981
+ ' sloth-agent scenarios list List scenarios and their options.',
982
+ ' sloth-agent scenarios create Create a No/Yes scenario.',
983
+ ' sloth-agent scenarios update Change a scenario or option.',
984
+ ' sloth-agent scenarios activate Make an option active.',
985
+ ' sloth-agent scenarios delete Remove a scenario.',
986
+ '',
987
+ 'Help:',
988
+ ' Run sloth-agent scenarios <command> --help for command-specific details.',
989
+ ...API_ORIGIN_HELP_LINES,
990
+ ].join('\n');
991
+ }
992
+ export function scenariosListHelpText() {
993
+ return [
994
+ 'Sloth Agent CLI - scenarios list',
995
+ '',
996
+ 'List each scenario, its active option, and account contributions.',
997
+ '',
998
+ 'Usage:',
999
+ ' sloth-agent scenarios [list] [--base-url URL] [-h]',
1000
+ '',
1001
+ 'Options:',
1002
+ ' --base-url URL Optional. Override the API origin.',
1003
+ ' -h, --help Show this help.',
1004
+ '',
1005
+ 'Behavior:',
1006
+ 'This command is read-only and requires agent:read.',
1007
+ ...API_ORIGIN_HELP_LINES,
1008
+ '',
1009
+ 'Output:',
1010
+ ' JSON with currency, forecastBasis, and scenarios. Each scenario contains',
1011
+ ' activeOptionId and options with isActive and account contributions.',
1012
+ ].join('\n');
1013
+ }
1014
+ export function scenariosCreateHelpText() {
1015
+ return [
1016
+ 'Sloth Agent CLI - scenarios create',
1017
+ '',
1018
+ 'Create a month-anchored choice and recalculate the Goal roadmap.',
1019
+ '',
1020
+ 'Usage:',
1021
+ ' sloth-agent scenarios create --month YYYY-MM --name NAME --account-ref REF',
1022
+ ' [--recurring-amount AMOUNT] [--one-off-amount AMOUNT] [--apply]',
1023
+ ' [--base-url URL] [-h]',
1024
+ '',
1025
+ 'Required:',
1026
+ ' --month YYYY-MM Month when this scenario begins.',
1027
+ ' --name NAME Question shown for the scenario, up to 60 characters.',
1028
+ ' --account-ref REF Exact accountRef from sloth-agent accounts.',
1029
+ '',
1030
+ 'Contribution: provide at least one:',
1031
+ ' --recurring-amount AMOUNT Optional monthly contribution in the budget currency.',
1032
+ ' --one-off-amount AMOUNT Optional contribution for this month.',
1033
+ ' AMOUNT accepts zero or a positive decimal with at most two decimal places.',
1034
+ ' At least one supplied amount must be positive.',
1035
+ '',
1036
+ 'Options:',
1037
+ ' --apply Optional. Save the scenario; otherwise preview it.',
1038
+ ' --base-url URL Optional. Override the API origin.',
1039
+ ' -h, --help Show this help.',
1040
+ '',
1041
+ 'Provide at least one positive contribution. A recurring contribution continues',
1042
+ 'until a later active scenario changes it. Creation adds No and Yes options and',
1043
+ 'activates Yes. It records a forecast assumption and does not move money.',
1044
+ '',
1045
+ 'Write behavior:',
1046
+ ' Without --apply, Sloth authenticates, calculates the result, and performs zero writes.',
1047
+ ' With --apply, Sloth saves the scenario using a write-enabled token with Allow changes.',
1048
+ ...API_ORIGIN_HELP_LINES,
1049
+ '',
1050
+ 'Output:',
1051
+ ' JSON with changed, forecastBasis, the proposed or saved scenario, and',
1052
+ ' recalculated Goals. Preview and apply use the same output contract.',
1053
+ '',
1054
+ 'Example:',
1055
+ ' sloth-agent scenarios create --month 2026-09 \\',
1056
+ ' --name "Deposit £100 into the shopping pot each month?" \\',
1057
+ ' --account-ref PASTE_THE_EXACT_ACCOUNT_REF_HERE --recurring-amount 100',
1058
+ ].join('\n');
1059
+ }
1060
+ export function scenariosUpdateHelpText() {
1061
+ return [
1062
+ 'Sloth Agent CLI - scenarios update',
1063
+ '',
1064
+ 'Change a scenario, one option, or an account contribution.',
1065
+ '',
1066
+ 'Usage:',
1067
+ ' sloth-agent scenarios update --month YYYY-MM [fields] [--apply]',
1068
+ ' [--base-url URL] [-h]',
1069
+ '',
1070
+ 'Required:',
1071
+ ' --month YYYY-MM Scenario month.',
1072
+ '',
1073
+ 'Fields:',
1074
+ ' --name NAME Rename the scenario, up to 60 characters.',
1075
+ ' --option-id ID Select an option ID, up to 200 characters.',
1076
+ ' --option-label LABEL Rename it, up to 60 characters; requires --option-id.',
1077
+ ' --account-ref REF Account for contribution changes.',
1078
+ ' --recurring-amount AMOUNT Set a monthly contribution; cannot be used',
1079
+ ' with --clear-recurring.',
1080
+ ' --clear-recurring Inherit the earlier recurring amount.',
1081
+ ' --one-off-amount AMOUNT Set this month\'s one-off contribution.',
1082
+ ' Amounts accept zero or a positive decimal with at most two decimal places.',
1083
+ ' Contribution fields require --account-ref, and --account-ref requires one',
1084
+ ' of those fields. Provide at least one field that changes the scenario.',
1085
+ '',
1086
+ 'Options:',
1087
+ ' --apply Optional. Save the change; otherwise preview it.',
1088
+ ' --base-url URL Optional. Override the API origin.',
1089
+ ' -h, --help Show this help.',
1090
+ '',
1091
+ 'Contribution changes use the active option when --option-id is omitted.',
1092
+ 'For recurring contributions, zero explicitly stops the earlier recurring amount.',
1093
+ '--clear-recurring removes this override so the earlier recurring amount continues.',
1094
+ '',
1095
+ 'Write behavior:',
1096
+ ' Without --apply, Sloth authenticates, calculates the result, and performs zero writes.',
1097
+ ' With --apply, Sloth saves the change using a write-enabled token with Allow changes.',
1098
+ ...API_ORIGIN_HELP_LINES,
1099
+ '',
1100
+ 'Output:',
1101
+ ' JSON with changed, forecastBasis, the proposed or saved scenario, and',
1102
+ ' recalculated Goals. Preview and apply use the same output contract.',
1103
+ ].join('\n');
1104
+ }
1105
+ export function scenariosActivateHelpText() {
1106
+ return [
1107
+ 'Sloth Agent CLI - scenarios activate',
1108
+ '',
1109
+ 'Select the option that controls the forecast and recalculates Goals.',
1110
+ '',
1111
+ 'Usage:',
1112
+ ' sloth-agent scenarios activate --month YYYY-MM --option-id ID [--apply]',
1113
+ ' [--base-url URL] [-h]',
1114
+ '',
1115
+ 'Required:',
1116
+ ' --month YYYY-MM Scenario month.',
1117
+ ' --option-id ID Exact option ID from scenarios list, up to 200 characters.',
1118
+ '',
1119
+ 'Options:',
1120
+ ' --apply Optional. Save the active option; otherwise preview it.',
1121
+ ' --base-url URL Optional. Override the API origin.',
1122
+ ' -h, --help Show this help.',
1123
+ '',
1124
+ 'Write behavior:',
1125
+ 'Without --apply, Sloth calculates the result and performs zero writes.',
1126
+ 'With --apply, Sloth saves the active option using a write-enabled token with Allow changes.',
1127
+ ...API_ORIGIN_HELP_LINES,
1128
+ '',
1129
+ 'Output:',
1130
+ ' JSON with changed, forecastBasis, the selected scenario, and recalculated Goals.',
1131
+ ].join('\n');
1132
+ }
1133
+ export function scenariosDeleteHelpText() {
1134
+ return [
1135
+ 'Sloth Agent CLI - scenarios delete',
1136
+ '',
1137
+ 'Remove one scenario. Sloth recalculates Goals without it.',
1138
+ '',
1139
+ 'Usage:',
1140
+ ' sloth-agent scenarios delete --month YYYY-MM [--apply] [--base-url URL] [-h]',
1141
+ '',
1142
+ 'Required:',
1143
+ ' --month YYYY-MM Scenario month.',
1144
+ '',
1145
+ 'Options:',
1146
+ ' --apply Optional. Remove the scenario; otherwise preview deletion.',
1147
+ ' --base-url URL Optional. Override the API origin.',
1148
+ ' -h, --help Show this help.',
1149
+ '',
1150
+ 'Write behavior:',
1151
+ 'Without --apply, Sloth calculates the result and performs zero writes.',
1152
+ 'With --apply, Sloth removes the scenario using a write-enabled token with Allow changes.',
1153
+ ...API_ORIGIN_HELP_LINES,
1154
+ '',
1155
+ 'Output:',
1156
+ ' JSON with changed, forecastBasis, deletedMonthKey, and recalculated Goals.',
1157
+ ].join('\n');
1158
+ }
961
1159
  export function askPartnerHelpText() {
962
1160
  return [
963
1161
  'Sloth Agent CLI — ask-partner',
@@ -1234,6 +1432,12 @@ export function commandHelpText(topic) {
1234
1432
  'goals-mark-spent': goalsMarkSpentHelpText,
1235
1433
  'goals-restore': goalsRestoreHelpText,
1236
1434
  'goals-delete': goalsDeleteHelpText,
1435
+ scenarios: scenariosHelpText,
1436
+ 'scenarios-list': scenariosListHelpText,
1437
+ 'scenarios-create': scenariosCreateHelpText,
1438
+ 'scenarios-update': scenariosUpdateHelpText,
1439
+ 'scenarios-activate': scenariosActivateHelpText,
1440
+ 'scenarios-delete': scenariosDeleteHelpText,
1237
1441
  'ask-partner': askPartnerHelpText,
1238
1442
  };
1239
1443
  return helpByTopic[topic]();
@@ -1556,6 +1760,77 @@ function hasFailures(value) {
1556
1760
  return false;
1557
1761
  return Array.isArray(value.failed) && value.failed.length > 0;
1558
1762
  }
1763
+ function scenarioRequestDescriptor(parsed, baseUrl) {
1764
+ const previewEndpoint = `${baseUrl}/api/agent/v1/scenarios/preview`;
1765
+ switch (parsed.command) {
1766
+ case 'scenarios-create': {
1767
+ const scenario = {
1768
+ monthKey: parsed.monthKey,
1769
+ name: parsed.name,
1770
+ accountRef: parsed.accountRef,
1771
+ ...(parsed.recurringAmount === undefined
1772
+ ? {}
1773
+ : { recurringAmount: parsed.recurringAmount }),
1774
+ ...(parsed.oneOffAmount === undefined
1775
+ ? {}
1776
+ : { oneOffAmount: parsed.oneOffAmount }),
1777
+ };
1778
+ return parsed.apply
1779
+ ? { endpoint: `${baseUrl}/api/agent/v1/scenarios`, method: 'POST', body: scenario }
1780
+ : {
1781
+ endpoint: previewEndpoint,
1782
+ method: 'POST',
1783
+ body: { action: 'create', scenario },
1784
+ };
1785
+ }
1786
+ case 'scenarios-update': {
1787
+ const updates = {
1788
+ ...(parsed.name === undefined ? {} : { name: parsed.name }),
1789
+ ...(parsed.optionId === undefined ? {} : { optionId: parsed.optionId }),
1790
+ ...(parsed.optionLabel === undefined ? {} : { optionLabel: parsed.optionLabel }),
1791
+ ...(parsed.accountRef === undefined ? {} : { accountRef: parsed.accountRef }),
1792
+ ...(parsed.recurringAmount === undefined
1793
+ ? {}
1794
+ : { recurringAmount: parsed.recurringAmount }),
1795
+ ...(parsed.oneOffAmount === undefined ? {} : { oneOffAmount: parsed.oneOffAmount }),
1796
+ };
1797
+ return parsed.apply
1798
+ ? {
1799
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}`,
1800
+ method: 'PATCH',
1801
+ body: updates,
1802
+ }
1803
+ : {
1804
+ endpoint: previewEndpoint,
1805
+ method: 'POST',
1806
+ body: { action: 'update', monthKey: parsed.monthKey, updates },
1807
+ };
1808
+ }
1809
+ case 'scenarios-activate':
1810
+ return parsed.apply
1811
+ ? {
1812
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}/activate`,
1813
+ method: 'POST',
1814
+ body: { optionId: parsed.optionId },
1815
+ }
1816
+ : {
1817
+ endpoint: previewEndpoint,
1818
+ method: 'POST',
1819
+ body: { action: 'activate', monthKey: parsed.monthKey, optionId: parsed.optionId },
1820
+ };
1821
+ case 'scenarios-delete':
1822
+ return parsed.apply
1823
+ ? {
1824
+ endpoint: `${baseUrl}/api/agent/v1/scenarios/${encodeURIComponent(parsed.monthKey)}`,
1825
+ method: 'DELETE',
1826
+ }
1827
+ : {
1828
+ endpoint: previewEndpoint,
1829
+ method: 'POST',
1830
+ body: { action: 'delete', monthKey: parsed.monthKey },
1831
+ };
1832
+ }
1833
+ }
1559
1834
  export async function runCli(argv = process.argv.slice(2), options = {}) {
1560
1835
  const environment = options.env ?? process.env;
1561
1836
  const fetchImplementation = options.fetch ?? globalThis.fetch;
@@ -2001,6 +2276,31 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2001
2276
  writeJson(writeStdout, data);
2002
2277
  return 0;
2003
2278
  }
2279
+ if (parsed.command === 'scenarios-list') {
2280
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/scenarios`, {
2281
+ method: 'GET',
2282
+ headers,
2283
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2284
+ });
2285
+ writeJson(writeStdout, parseApiResponse('scenarios-list', await parseHttpResponse(response, token)));
2286
+ return 0;
2287
+ }
2288
+ if (parsed.command === 'scenarios-create'
2289
+ || parsed.command === 'scenarios-update'
2290
+ || parsed.command === 'scenarios-activate'
2291
+ || parsed.command === 'scenarios-delete') {
2292
+ const request = scenarioRequestDescriptor(parsed, baseUrl);
2293
+ const response = await fetchImplementation(request.endpoint, {
2294
+ method: request.method,
2295
+ headers: request.body === undefined
2296
+ ? headers
2297
+ : { ...headers, 'Content-Type': 'application/json' },
2298
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
2299
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2300
+ });
2301
+ writeJson(writeStdout, parseApiResponse('scenarios-mutation', await parseHttpResponse(response, token)));
2302
+ return 0;
2303
+ }
2004
2304
  if (parsed.command === 'assign') {
2005
2305
  const payload = assignmentPayload;
2006
2306
  const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
package/dist/contracts.js CHANGED
@@ -1054,6 +1054,101 @@ function isGoalDeleteResponse(value) {
1054
1054
  && typeof value.deletedGoalId === 'string'
1055
1055
  && value.deletedGoalId.trim().length > 0);
1056
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
+ }
1057
1152
  function isReceiptConfirmation(value) {
1058
1153
  try {
1059
1154
  validateReceiptConfirmation(value);
@@ -1150,11 +1245,15 @@ export function parseApiResponse(command, value) {
1150
1245
  ? isPartnerResponse(value)
1151
1246
  : command === 'goals-list'
1152
1247
  ? isGoalsResponse(value)
1153
- : command === 'goals-preview'
1154
- ? isGoalPreviewResponse(value)
1155
- : command === 'goals-delete'
1156
- ? isGoalDeleteResponse(value)
1157
- : isGoalMutationResponse(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);
1158
1257
  if (!valid) {
1159
1258
  const label = command === 'assign' ? 'assignment' : command;
1160
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.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {