@slothmoney/agent-cli 0.18.1 → 0.19.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 - 2026-08-26
4
+
5
+ - Make every Goal use one explicit personal Goal-funding account and expose
6
+ the calculated `forecastMonthKey`, effective priority, account details, and
7
+ `forecastBasis` in Goal results.
8
+ - Make `goals create` preview against Sloth's active scenario on the server;
9
+ preview performs no writes and `--apply` remains the write boundary.
10
+ - Add `--account-ref` and `--priority` to Goal creation, allow account
11
+ reassignment during Goal updates, and rename account eligibility to
12
+ `isGoalFundingAccount` / `--goal-funding-account`.
13
+ - Surface corrective API error codes without adding affordability judgments or
14
+ desired-date comparisons to the CLI.
15
+
3
16
  ## 0.18.1 - 2026-08-25
4
17
 
5
18
  - Require the deployed historical-activity response for `budget status` and
package/README.md CHANGED
@@ -584,7 +584,7 @@ sloth-agent accounts
584
584
  The command is read-only and cache-only: it does not refresh linked banks or
585
585
  change account data. Each result contains an opaque `accountRef`, personal or
586
586
  joint ownership, connected or manual source, native balance/currency when
587
- known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalSavingsSource`.
587
+ known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalFundingAccount`.
588
588
  Missing values are JSON
589
589
  `null`; currencies are never converted or combined. Partner personal accounts
590
590
  are excluded, while enabled shared joint accounts follow Sloth's existing
@@ -604,9 +604,9 @@ Copy the value from `sloth-agent accounts`. Account references are the public
604
604
  account identifier for transaction filtering.
605
605
 
606
606
  Account changes are previews unless `--apply` is present. Connected accounts
607
- support only goal-savings membership. Manual current accounts support their
607
+ support only Goal-funding eligibility. Manual current accounts support their
608
608
  institution, name, currency, and ownership. Manual balance accounts also
609
- support balance, Savings/Investments type, and goal-savings membership.
609
+ support balance, Savings/Investments type, and Goal-funding eligibility.
610
610
  Partner-owned shared accounts return an explanatory error.
611
611
 
612
612
  ```bash
@@ -618,11 +618,11 @@ sloth-agent accounts update \
618
618
  --ownership individual \
619
619
  --balance-amount 12500.75 \
620
620
  --account-type investments \
621
- --goal-savings-source false
621
+ --goal-funding-account false
622
622
 
623
623
  sloth-agent accounts update \
624
624
  --account-ref sloth_account_v1_... \
625
- --goal-savings-source false \
625
+ --goal-funding-account false \
626
626
  --apply
627
627
  ```
628
628
 
@@ -664,16 +664,24 @@ Goal writes are previews unless `--apply` is present:
664
664
  sloth-agent goals create \
665
665
  --name "Emergency fund" \
666
666
  --target-amount 12000 \
667
- --type keep
667
+ --type keep \
668
+ --account-ref sloth_account_v1_...
668
669
 
669
670
  sloth-agent goals create \
670
671
  --name "Wedding" \
671
672
  --target-amount 22000 \
672
673
  --target-month 2027-06 \
673
674
  --type spend \
675
+ --account-ref sloth_account_v1_... \
674
676
  --apply
675
677
  ```
676
678
 
679
+ Without `--apply`, Goal creation authenticates and asks Sloth to calculate the
680
+ Goal without writing it. Preview and apply use the same active-scenario planner
681
+ and return `forecastMonthKey`, the effective priority, the funding account, and
682
+ `forecastBasis`. A null forecast includes the final projected month. The CLI
683
+ does not compare the desired and forecast dates or return affordability advice.
684
+
677
685
  Every goal is either Keep or Spend. A Keep goal continues reserving its funded
678
686
  money. A Spend goal reserves money until you explicitly mark it spent. Goal
679
687
  list, create, and update output includes lowercase `goalType` and nullable
@@ -687,6 +695,7 @@ move it in the priority order:
687
695
  sloth-agent goals update \
688
696
  --goal-id goal-id \
689
697
  --target-month 2027-12 \
698
+ --account-ref sloth_account_v1_... \
690
699
  --type spend \
691
700
  --apply
692
701
 
@@ -715,13 +724,10 @@ required recovery action. Restoring clears `spentAt` and returns the goal to
715
724
  allocation at its saved priority. Deleting a goal also removes its forecast
716
725
  assignments and drift history.
717
726
 
718
- Goal sharing remains app-managed. Change an active shared goal's pot-tracked
719
- target amount in the Sloth Budget app, where account balances can be
720
- reallocated across goals in priority order. Goal list output includes a
727
+ Goal sharing remains app-managed. Goal list output includes a
721
728
  one-based `priority`; `1` is highest. Moving one goal automatically shifts the
722
- goals between its old and new positions. The priority option must be used on
723
- its own. Forecast assignments and shared pot progress are browser-owned
724
- derived state and refresh when the owner next opens the Forecast screen.
729
+ goals between its old and new positions. Sloth recalculates the active-scenario
730
+ roadmap before every applied Goal mutation and returns the updated forecast.
725
731
 
726
732
  Read uncategorised contributions to the joint budget:
727
733
 
package/dist/args.js CHANGED
@@ -389,7 +389,7 @@ function parseAccounts(args, baseUrl) {
389
389
  '--ownership',
390
390
  '--balance-amount',
391
391
  '--account-type',
392
- '--goal-savings-source',
392
+ '--goal-funding-account',
393
393
  ]));
394
394
  const institutionName = values.get('--institution-name');
395
395
  const accountName = values.get('--account-name');
@@ -397,7 +397,7 @@ function parseAccounts(args, baseUrl) {
397
397
  const ownershipValue = values.get('--ownership');
398
398
  const balanceValue = values.get('--balance-amount');
399
399
  const accountTypeValue = values.get('--account-type');
400
- const sourceValue = values.get('--goal-savings-source');
400
+ const sourceValue = values.get('--goal-funding-account');
401
401
  if (currencyValue !== undefined && !/^[A-Za-z]{3}$/.test(currencyValue)) {
402
402
  throw new UsageError('--currency must be a three-letter currency code');
403
403
  }
@@ -412,7 +412,7 @@ function parseAccounts(args, baseUrl) {
412
412
  throw new UsageError('--account-type must be savings or investments');
413
413
  }
414
414
  if (sourceValue !== undefined && sourceValue !== 'true' && sourceValue !== 'false') {
415
- throw new UsageError('--goal-savings-source must be true or false');
415
+ throw new UsageError('--goal-funding-account must be true or false');
416
416
  }
417
417
  const update = {
418
418
  ...(institutionName === undefined
@@ -429,7 +429,7 @@ function parseAccounts(args, baseUrl) {
429
429
  ...(accountTypeValue === undefined
430
430
  ? {}
431
431
  : { accountType: accountTypeValue }),
432
- ...(sourceValue === undefined ? {} : { isGoalSavingsSource: sourceValue === 'true' }),
432
+ ...(sourceValue === undefined ? {} : { isGoalFundingAccount: sourceValue === 'true' }),
433
433
  };
434
434
  if (Object.keys(update).length === 0) {
435
435
  throw new UsageError('accounts update requires at least one field to update');
@@ -652,6 +652,8 @@ function parseGoals(args, baseUrl) {
652
652
  let targetAmount;
653
653
  let targetMonthKey;
654
654
  let goalType;
655
+ let fundingAccountRef;
656
+ let priority;
655
657
  let apply = false;
656
658
  for (let index = 0; index < args.length; index += 1) {
657
659
  const argument = args[index];
@@ -667,7 +669,9 @@ function parseGoals(args, baseUrl) {
667
669
  if (option !== '--name'
668
670
  && option !== '--target-amount'
669
671
  && option !== '--target-month'
670
- && option !== '--type') {
672
+ && option !== '--type'
673
+ && option !== '--account-ref'
674
+ && option !== '--priority') {
671
675
  throw new UsageError(`Unknown goals create option: ${argument}`);
672
676
  }
673
677
  const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
@@ -682,9 +686,15 @@ function parseGoals(args, baseUrl) {
682
686
  else if (option === '--target-month') {
683
687
  targetMonthKey = setOnce(targetMonthKey, parseGoalMonthKey(value, option), option);
684
688
  }
685
- else {
689
+ else if (option === '--type') {
686
690
  goalType = setOnce(goalType, parseGoalType(value), option);
687
691
  }
692
+ else if (option === '--account-ref') {
693
+ fundingAccountRef = setOnce(fundingAccountRef, parseAccountRef(value), option);
694
+ }
695
+ else {
696
+ priority = setOnce(priority, parseGoalPriority(value), option);
697
+ }
688
698
  }
689
699
  if (!name)
690
700
  throw new UsageError('goals create requires --name <name>');
@@ -694,12 +704,17 @@ function parseGoals(args, baseUrl) {
694
704
  if (goalType === undefined) {
695
705
  throw new UsageError('goals create requires --type <keep|spend>');
696
706
  }
707
+ if (fundingAccountRef === undefined) {
708
+ throw new UsageError('goals create requires --account-ref <accountRef>');
709
+ }
697
710
  return withBaseUrl({
698
711
  command: 'goals-create',
699
712
  name,
700
713
  targetAmount,
701
714
  ...(targetMonthKey === undefined ? {} : { targetMonthKey }),
702
715
  goalType,
716
+ fundingAccountRef,
717
+ ...(priority === undefined ? {} : { priority }),
703
718
  apply,
704
719
  }, baseUrl);
705
720
  }
@@ -709,6 +724,7 @@ function parseGoals(args, baseUrl) {
709
724
  let targetAmount;
710
725
  let targetMonthKey;
711
726
  let goalType;
727
+ let fundingAccountRef;
712
728
  let priority;
713
729
  let apply = false;
714
730
  for (let index = 0; index < args.length; index += 1) {
@@ -734,6 +750,7 @@ function parseGoals(args, baseUrl) {
734
750
  && option !== '--target-amount'
735
751
  && option !== '--target-month'
736
752
  && option !== '--type'
753
+ && option !== '--account-ref'
737
754
  && option !== '--priority') {
738
755
  throw new UsageError(`Unknown goals update option: ${argument}`);
739
756
  }
@@ -758,6 +775,9 @@ function parseGoals(args, baseUrl) {
758
775
  else if (option === '--priority') {
759
776
  priority = setOnce(priority, parseGoalPriority(value), option);
760
777
  }
778
+ else if (option === '--account-ref') {
779
+ fundingAccountRef = setOnce(fundingAccountRef, parseAccountRef(value), option);
780
+ }
761
781
  else {
762
782
  goalType = setOnce(goalType, parseGoalType(value), option);
763
783
  }
@@ -768,16 +788,10 @@ function parseGoals(args, baseUrl) {
768
788
  && targetAmount === undefined
769
789
  && targetMonthKey === undefined
770
790
  && goalType === undefined
791
+ && fundingAccountRef === undefined
771
792
  && priority === undefined) {
772
793
  throw new UsageError('goals update requires at least one field to update');
773
794
  }
774
- if (priority !== undefined
775
- && (name !== undefined
776
- || targetAmount !== undefined
777
- || targetMonthKey !== undefined
778
- || goalType !== undefined)) {
779
- throw new UsageError('--priority must be used on its own');
780
- }
781
795
  return withBaseUrl({
782
796
  command: 'goals-update',
783
797
  goalId,
@@ -785,6 +799,7 @@ function parseGoals(args, baseUrl) {
785
799
  ...(targetAmount === undefined ? {} : { targetAmount }),
786
800
  ...(targetMonthKey === undefined ? {} : { targetMonthKey }),
787
801
  ...(goalType === undefined ? {} : { goalType }),
802
+ ...(fundingAccountRef === undefined ? {} : { fundingAccountRef }),
788
803
  ...(priority === undefined ? {} : { priority }),
789
804
  apply,
790
805
  }, baseUrl);
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.18.1';
9
+ export const CLI_VERSION = '0.19.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 = [
@@ -61,7 +61,8 @@ export function usageText() {
61
61
  ' sloth-agent receipts remove --transaction-ref REF --revision N [--apply]',
62
62
  ' sloth-agent goals [list] [--base-url URL]',
63
63
  ' sloth-agent goals create --name NAME --target-amount AMOUNT',
64
- ' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
64
+ ' --type keep|spend --account-ref REF [--target-month YYYY-MM]',
65
+ ' [--priority POSITION] [--apply] [--base-url URL]',
65
66
  ' sloth-agent goals update --goal-id ID [fields] [--apply] [--base-url URL]',
66
67
  ' sloth-agent goals mark-spent --goal-id ID [--apply] [--base-url URL]',
67
68
  ' sloth-agent goals restore --goal-id ID [--apply] [--base-url URL]',
@@ -354,7 +355,7 @@ export function accountsHelpText() {
354
355
  ' accounts[].ownership personal or joint',
355
356
  ' accounts[].balanceAmount and currency in the native currency when known',
356
357
  ' accounts[].connectionState and lastBalanceUpdatedAt for freshness',
357
- ' accounts[].isGoalSavingsSource whether the owner uses it for goal savings',
358
+ ' accounts[].isGoalFundingAccount whether Goals may use the account',
358
359
  ].join('\n');
359
360
  }
360
361
  export function accountsUpdateHelpText() {
@@ -376,13 +377,13 @@ export function accountsUpdateHelpText() {
376
377
  ' --ownership individual|joint Manual account ownership.',
377
378
  ' --balance-amount AMOUNT Balance-only account balance.',
378
379
  ' --account-type savings|investments Balance-only account type.',
379
- ' --goal-savings-source true|false Goal-savings membership.',
380
+ ' --goal-funding-account true|false Whether Goals may use this account.',
380
381
  '',
381
382
  'Write behavior:',
382
383
  ' Without --apply, returns a JSON preview without credentials or a network request.',
383
384
  ' With --apply, requires agent:write on a write-enabled token and updates saved Sloth metadata.',
384
- ' Connected accounts support only --goal-savings-source.',
385
- ' Manual current accounts cannot change type, balance, or goal-savings membership.',
385
+ ' Connected accounts support only --goal-funding-account.',
386
+ ' Manual current accounts cannot change type, balance, or Goal-funding membership.',
386
387
  ' Partner-owned shared accounts cannot be changed.',
387
388
  ' Unknown, disconnected, or inaccessible references return Account not found.',
388
389
  ...API_ORIGIN_HELP_LINES,
@@ -720,7 +721,7 @@ export function goalsHelpText() {
720
721
  return [
721
722
  'Sloth Agent CLI — goals',
722
723
  '',
723
- 'List, create, update, mark spent, restore, or delete your savings goals.',
724
+ 'List, preview, create, update, mark spent, restore, or delete account-funded Goals.',
724
725
  '',
725
726
  'Commands:',
726
727
  ' sloth-agent goals list List goals; "sloth-agent goals" is equivalent.',
@@ -754,9 +755,9 @@ export function goalsListHelpText() {
754
755
  ' This command is read-only.',
755
756
  '',
756
757
  'Output:',
757
- ' JSON containing currency and goals. Each goal contains id, name, priority,',
758
- ' targetAmount, targetMonthKey, goalType, nullable spentAt, and',
759
- ' sharedWithPartner.',
758
+ ' JSON containing currency, forecastBasis, and goals. Each goal includes its',
759
+ ' effectivePriority, funding account, desired targetMonthKey, and calculated',
760
+ ' forecastMonthKey.',
760
761
  ].join('\n');
761
762
  }
762
763
  export function goalsCreateHelpText() {
@@ -766,30 +767,33 @@ export function goalsCreateHelpText() {
766
767
  'Preview or create a goal.',
767
768
  '',
768
769
  'Usage:',
769
- ' sloth-agent goals create --name NAME --target-amount AMOUNT --type keep|spend [options]',
770
+ ' sloth-agent goals create --name NAME --target-amount AMOUNT --type keep|spend --account-ref REF [options]',
770
771
  '',
771
772
  'Options:',
772
773
  ' --name NAME Required. Goal name, 1 to 200 characters.',
773
774
  ' --target-amount AMOUNT Required. Positive major-unit amount with up to 2 decimals.',
774
775
  ' --type keep|spend Required. Keep reserves funded money; Spend is spent later.',
775
- ' --target-month YYYY-MM Optional. Target calendar month.',
776
+ ' --account-ref REF Required. Personal Goal-funding account from accounts list.',
777
+ ' --target-month YYYY-MM Optional. Desired calendar month; it does not change the forecast.',
778
+ ' --priority POSITION Optional. One-based priority; defaults to append.',
776
779
  ' --apply Optional. Create the goal in Sloth Money.',
777
780
  ' --base-url URL Optional. Override the API origin.',
778
781
  ' -h, --help Show this help.',
779
782
  ...API_ORIGIN_HELP_LINES,
780
783
  '',
781
784
  'Safety:',
782
- ' Without --apply, the command returns a dry-run preview and does not write.',
785
+ ' Without --apply, the command authenticates and asks Sloth to calculate the Goal.',
786
+ ' The server performs no writes for a preview.',
783
787
  ' Applying requires a write-enabled token created with Allow changes.',
784
- ' New goals are private to the owner and appended to the existing goal order.',
788
+ ' New goals are private to the owner and append unless --priority is supplied.',
785
789
  '',
786
790
  'Example:',
787
- ' sloth-agent goals create --name "Emergency fund" --target-amount 12000 --type keep',
788
- ' sloth-agent goals create --name "Wedding" --target-amount 22000 --type spend --target-month 2027-06 --apply',
791
+ ' sloth-agent goals create --name "Emergency fund" --target-amount 12000 --type keep --account-ref REF',
792
+ ' sloth-agent goals create --name "Wedding" --target-amount 22000 --type spend --account-ref REF --target-month 2027-06 --apply',
789
793
  '',
790
794
  'Output:',
791
- ' Preview mode returns dryRun, method, endpoint, and payload.',
792
- ' Apply mode returns the persisted goal and currency from the 201 response.',
795
+ ' Preview and apply return the Goal, currency, calculated forecastMonthKey,',
796
+ ' effective priority, funding-account details, and forecastBasis.',
793
797
  ].join('\n');
794
798
  }
795
799
  export function goalsUpdateHelpText() {
@@ -808,6 +812,7 @@ export function goalsUpdateHelpText() {
808
812
  ' --target-month YYYY-MM Optional. Replace the target month.',
809
813
  ' --clear-target-month Optional. Remove the target month.',
810
814
  ' --type keep|spend Optional. Change how funded money is treated.',
815
+ ' --account-ref REF Optional. Reassign to another personal Goal-funding account.',
811
816
  ' --priority POSITION Optional. Positive whole-number position; 1 is highest.',
812
817
  ' --apply Optional. Write the partial update.',
813
818
  ' --base-url URL Optional. Override the API origin.',
@@ -816,17 +821,13 @@ export function goalsUpdateHelpText() {
816
821
  '',
817
822
  'Constraints:',
818
823
  ' Provide at least one field to update.',
819
- ' Priority must be updated on its own.',
820
824
  ' Priority 1 is highest. The position cannot exceed the current goal count.',
821
825
  ' Moving a goal shifts the intervening goals automatically.',
822
- ' Forecast assignments and shared progress refresh when the owner next opens',
823
- ' the Forecast screen.',
826
+ ' Sloth recalculates the Goal roadmap before saving the update.',
824
827
  ' Set and clear target-month options are mutually exclusive.',
825
828
  ' Restore a spent goal before changing its type.',
826
- ' Change active shared pot target amounts in the Sloth Budget app, where',
827
- ' account balances can be reconciled across goals in priority order.',
828
- ' Sharing remains app-managed. Updates to an already shared goal remain visible',
829
- ' to the connected partner.',
829
+ ' Sharing remains app-managed. Updates to a shared Goal remain visible to the',
830
+ ' connected partner, but its assigned account remains private to the owner.',
830
831
  '',
831
832
  'Safety:',
832
833
  ' Without --apply, the command returns a dry-run preview and does not write.',
@@ -838,7 +839,8 @@ export function goalsUpdateHelpText() {
838
839
  '',
839
840
  'Output:',
840
841
  ' Preview mode returns dryRun, method, endpoint, and payload.',
841
- ' Apply mode returns the complete persisted goal and currency.',
842
+ ' Apply mode returns the complete persisted Goal, calculated forecastMonthKey,',
843
+ ' effective priority, funding-account details, currency, and forecastBasis.',
842
844
  ].join('\n');
843
845
  }
844
846
  export function goalsMarkSpentHelpText() {
@@ -1207,23 +1209,6 @@ export function commandHelpText(topic) {
1207
1209
  function writeJson(write, data) {
1208
1210
  write(`${JSON.stringify(data, null, 2)}\n`);
1209
1211
  }
1210
- function withListedGoalPriorities(value) {
1211
- const response = value;
1212
- return {
1213
- ...response,
1214
- goals: response.goals.map((goal, index) => ({
1215
- ...goal,
1216
- priority: index + 1,
1217
- })),
1218
- };
1219
- }
1220
- function withUpdatedGoalPriority(value, priority) {
1221
- const response = value;
1222
- return {
1223
- ...response,
1224
- goal: { ...response.goal, priority },
1225
- };
1226
- }
1227
1212
  function redact(value, token) {
1228
1213
  return token ? value.split(token).join('[REDACTED]') : value;
1229
1214
  }
@@ -1404,7 +1389,11 @@ async function parseHttpResponse(response, token) {
1404
1389
  && typeof data.error === 'string')
1405
1390
  ? data.error
1406
1391
  : `Agent API request failed with status ${response.status}`;
1407
- throw new ApiError(redact(message, token), response.status);
1392
+ const code = (data
1393
+ && typeof data === 'object'
1394
+ && 'code' in data
1395
+ && typeof data.code === 'string') ? data.code : undefined;
1396
+ throw new ApiError(redact(message, token), response.status, code);
1408
1397
  }
1409
1398
  return data;
1410
1399
  }
@@ -1891,7 +1880,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1891
1880
  return 0;
1892
1881
  }
1893
1882
  if (parsed.command === 'goals-create') {
1894
- const endpoint = `${baseUrl}/api/agent/v1/goals`;
1883
+ const endpoint = `${baseUrl}/api/agent/v1/goals${parsed.apply ? '' : '/preview'}`;
1895
1884
  const payload = {
1896
1885
  name: parsed.name,
1897
1886
  targetAmount: parsed.targetAmount,
@@ -1899,16 +1888,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1899
1888
  ? {}
1900
1889
  : { targetMonthKey: parsed.targetMonthKey }),
1901
1890
  goalType: parsed.goalType,
1891
+ fundingAccountRef: parsed.fundingAccountRef,
1892
+ ...(parsed.priority === undefined ? {} : { priority: parsed.priority }),
1902
1893
  };
1903
- if (!parsed.apply) {
1904
- writeJson(writeStdout, {
1905
- dryRun: true,
1906
- endpoint,
1907
- method: 'POST',
1908
- payload,
1909
- });
1910
- return 0;
1911
- }
1912
1894
  const response = await fetchImplementation(endpoint, {
1913
1895
  method: 'POST',
1914
1896
  headers: {
@@ -1918,7 +1900,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1918
1900
  body: JSON.stringify(payload),
1919
1901
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1920
1902
  });
1921
- const data = parseApiResponse('goals-create', await parseHttpResponse(response, token));
1903
+ const data = parseApiResponse(parsed.apply ? 'goals-create' : 'goals-preview', await parseHttpResponse(response, token));
1922
1904
  writeJson(writeStdout, data);
1923
1905
  return 0;
1924
1906
  }
@@ -1938,6 +1920,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1938
1920
  ...(parsed.goalType === undefined
1939
1921
  ? {}
1940
1922
  : { goalType: parsed.goalType }),
1923
+ ...(parsed.fundingAccountRef === undefined
1924
+ ? {}
1925
+ : { fundingAccountRef: parsed.fundingAccountRef }),
1941
1926
  ...(parsed.priority === undefined
1942
1927
  ? {}
1943
1928
  : { priority: parsed.priority }),
@@ -1962,9 +1947,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1962
1947
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1963
1948
  });
1964
1949
  const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1965
- writeJson(writeStdout, parsed.command !== 'goals-update' || parsed.priority === undefined
1966
- ? data
1967
- : withUpdatedGoalPriority(data, parsed.priority));
1950
+ writeJson(writeStdout, data);
1968
1951
  return 0;
1969
1952
  }
1970
1953
  if (parsed.command === 'goals-delete') {
@@ -2059,7 +2042,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2059
2042
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2060
2043
  });
2061
2044
  const data = parseApiResponse('goals-list', await parseHttpResponse(response, token));
2062
- writeJson(writeStdout, withListedGoalPriorities(data));
2045
+ writeJson(writeStdout, data);
2063
2046
  return 0;
2064
2047
  }
2065
2048
  if (parsed.command === 'budget' || parsed.command === 'budget-status') {
@@ -2104,7 +2087,8 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2104
2087
  catch (error) {
2105
2088
  const exitCode = error instanceof CliError ? error.exitCode : 1;
2106
2089
  const message = error instanceof Error ? error.message : String(error);
2107
- writeStderr(`${redact(message, token)}\n`);
2090
+ const prefix = error instanceof ApiError && error.code ? `${error.code}: ` : '';
2091
+ writeStderr(`${prefix}${redact(message, token)}\n`);
2108
2092
  return exitCode;
2109
2093
  }
2110
2094
  }
package/dist/contracts.js CHANGED
@@ -610,6 +610,25 @@ function hasOnlyFields(value, fields) {
610
610
  const allowed = new Set(fields);
611
611
  return Object.keys(value).every((key) => allowed.has(key));
612
612
  }
613
+ function isMonthKeyOrNull(value) {
614
+ return value === null || (typeof value === 'string'
615
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value));
616
+ }
617
+ function isPlannedGoalFields(value) {
618
+ return (typeof value.name === 'string'
619
+ && value.name.trim().length > 0
620
+ && typeof value.targetAmount === 'number'
621
+ && Number.isFinite(value.targetAmount)
622
+ && value.targetAmount > 0
623
+ && isMonthKeyOrNull(value.targetMonthKey)
624
+ && isMonthKeyOrNull(value.forecastMonthKey)
625
+ && isGoalType(value.goalType)
626
+ && Number.isSafeInteger(value.effectivePriority)
627
+ && Number(value.effectivePriority) > 0
628
+ && isAccountRef(value.fundingAccountRef)
629
+ && typeof value.fundingAccountLabel === 'string'
630
+ && value.fundingAccountLabel.trim().length > 0);
631
+ }
613
632
  function isGoal(value) {
614
633
  return (isObject(value)
615
634
  && hasOnlyFields(value, [
@@ -617,20 +636,17 @@ function isGoal(value) {
617
636
  'name',
618
637
  'targetAmount',
619
638
  'targetMonthKey',
639
+ 'forecastMonthKey',
620
640
  'goalType',
621
641
  'spentAt',
622
642
  'sharedWithPartner',
643
+ 'effectivePriority',
644
+ 'fundingAccountRef',
645
+ 'fundingAccountLabel',
623
646
  ])
624
647
  && typeof value.id === 'string'
625
648
  && value.id.trim().length > 0
626
- && typeof value.name === 'string'
627
- && value.name.trim().length > 0
628
- && (value.targetAmount === null
629
- || (typeof value.targetAmount === 'number' && Number.isFinite(value.targetAmount)))
630
- && (value.targetMonthKey === null
631
- || (typeof value.targetMonthKey === 'string'
632
- && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.targetMonthKey)))
633
- && isGoalType(value.goalType)
649
+ && isPlannedGoalFields(value)
634
650
  && (value.goalType === 'spend'
635
651
  ? value.spentAt === null || isIsoDateTime(value.spentAt)
636
652
  : value.spentAt === null)
@@ -844,7 +860,7 @@ function isAccount(value) {
844
860
  'source',
845
861
  'lastBalanceUpdatedAt',
846
862
  'connectionState',
847
- 'isGoalSavingsSource',
863
+ 'isGoalFundingAccount',
848
864
  ])
849
865
  && isAccountRef(value.accountRef)
850
866
  && isNullableNonEmptyString(value.accountName)
@@ -863,7 +879,7 @@ function isAccount(value) {
863
879
  || value.connectionState === 'expired'
864
880
  || value.connectionState === 'manual'
865
881
  || value.connectionState === 'unknown')
866
- && typeof value.isGoalSavingsSource === 'boolean');
882
+ && typeof value.isGoalFundingAccount === 'boolean');
867
883
  }
868
884
  function isAccountsResponse(value) {
869
885
  return (isObject(value)
@@ -929,19 +945,55 @@ function isInvestmentsResponse(value) {
929
945
  && holdings.every(isInvestmentHolding));
930
946
  }));
931
947
  }
948
+ function isForecastBasis(value) {
949
+ return isObject(value)
950
+ && hasOnlyFields(value, [
951
+ 'calculatedAt',
952
+ 'activeScenarioRevision',
953
+ 'projectionThroughMonthKey',
954
+ ])
955
+ && isIsoDateTime(value.calculatedAt)
956
+ && Number.isSafeInteger(value.activeScenarioRevision)
957
+ && Number(value.activeScenarioRevision) >= 0
958
+ && isMonthKeyOrNull(value.projectionThroughMonthKey) === true
959
+ && value.projectionThroughMonthKey !== null;
960
+ }
961
+ function isGoalPreview(value) {
962
+ return isObject(value)
963
+ && hasOnlyFields(value, [
964
+ 'name',
965
+ 'targetAmount',
966
+ 'targetMonthKey',
967
+ 'forecastMonthKey',
968
+ 'goalType',
969
+ 'effectivePriority',
970
+ 'fundingAccountRef',
971
+ 'fundingAccountLabel',
972
+ ])
973
+ && isPlannedGoalFields(value);
974
+ }
932
975
  function isGoalsResponse(value) {
933
976
  return (isObject(value)
934
- && hasOnlyFields(value, ['currency', 'goals'])
977
+ && hasOnlyFields(value, ['currency', 'forecastBasis', 'goals'])
935
978
  && isCurrency(value.currency)
979
+ && isForecastBasis(value.forecastBasis)
936
980
  && Array.isArray(value.goals)
937
981
  && value.goals.every(isGoal));
938
982
  }
939
983
  function isGoalMutationResponse(value) {
940
984
  return (isObject(value)
941
- && hasOnlyFields(value, ['currency', 'goal'])
985
+ && hasOnlyFields(value, ['currency', 'forecastBasis', 'goal'])
942
986
  && isCurrency(value.currency)
987
+ && isForecastBasis(value.forecastBasis)
943
988
  && isGoal(value.goal));
944
989
  }
990
+ function isGoalPreviewResponse(value) {
991
+ return isObject(value)
992
+ && hasOnlyFields(value, ['currency', 'forecastBasis', 'goal'])
993
+ && isCurrency(value.currency)
994
+ && isForecastBasis(value.forecastBasis)
995
+ && isGoalPreview(value.goal);
996
+ }
945
997
  function isGoalDeleteResponse(value) {
946
998
  return (isObject(value)
947
999
  && hasOnlyFields(value, ['deleted', 'deletedGoalId'])
@@ -1043,9 +1095,11 @@ export function parseApiResponse(command, value) {
1043
1095
  ? isPartnerResponse(value)
1044
1096
  : command === 'goals-list'
1045
1097
  ? isGoalsResponse(value)
1046
- : command === 'goals-delete'
1047
- ? isGoalDeleteResponse(value)
1048
- : isGoalMutationResponse(value);
1098
+ : command === 'goals-preview'
1099
+ ? isGoalPreviewResponse(value)
1100
+ : command === 'goals-delete'
1101
+ ? isGoalDeleteResponse(value)
1102
+ : isGoalMutationResponse(value);
1049
1103
  if (!valid) {
1050
1104
  const label = command === 'assign' ? 'assignment' : command;
1051
1105
  throw new ApiError(`Invalid ${label} response from the Agent API`);
package/dist/errors.js CHANGED
@@ -17,8 +17,10 @@ export class ConfigError extends CliError {
17
17
  }
18
18
  export class ApiError extends CliError {
19
19
  status;
20
- constructor(message, status) {
20
+ code;
21
+ constructor(message, status, code) {
21
22
  super(message, 1);
22
23
  this.status = status;
24
+ this.code = code;
23
25
  }
24
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {