@slothmoney/agent-cli 0.20.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,14 @@
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
+
3
12
  ## 0.20.0 - 2026-08-27
4
13
 
5
14
  - 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.0 -- 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
 
@@ -748,6 +751,52 @@ one-based `priority`; `1` is highest. Moving one goal automatically shifts the
748
751
  goals between its old and new positions. Sloth recalculates the active-scenario
749
752
  roadmap before every applied Goal mutation and returns the updated forecast.
750
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
+
751
800
  Read uncategorised contributions to the joint budget:
752
801
 
753
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('.');
@@ -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.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 = [
@@ -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:',
@@ -958,6 +964,192 @@ export function goalsDeleteHelpText() {
958
964
  ' Apply mode returns deleted and deletedGoalId.',
959
965
  ].join('\n');
960
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
+ }
961
1153
  export function askPartnerHelpText() {
962
1154
  return [
963
1155
  'Sloth Agent CLI — ask-partner',
@@ -1234,6 +1426,12 @@ export function commandHelpText(topic) {
1234
1426
  'goals-mark-spent': goalsMarkSpentHelpText,
1235
1427
  'goals-restore': goalsRestoreHelpText,
1236
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,
1237
1435
  'ask-partner': askPartnerHelpText,
1238
1436
  };
1239
1437
  return helpByTopic[topic]();
@@ -1556,6 +1754,77 @@ function hasFailures(value) {
1556
1754
  return false;
1557
1755
  return Array.isArray(value.failed) && value.failed.length > 0;
1558
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
+ }
1559
1828
  export async function runCli(argv = process.argv.slice(2), options = {}) {
1560
1829
  const environment = options.env ?? process.env;
1561
1830
  const fetchImplementation = options.fetch ?? globalThis.fetch;
@@ -2001,6 +2270,31 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2001
2270
  writeJson(writeStdout, data);
2002
2271
  return 0;
2003
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
+ }
2004
2298
  if (parsed.command === 'assign') {
2005
2299
  const payload = assignmentPayload;
2006
2300
  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.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {