@slothmoney/agent-cli 0.19.0 → 0.20.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.20.0 - 2026-08-27
4
+
5
+ - Add `portfolio` views for personal, partner-shared, and combined household
6
+ savings and investments.
7
+ - Add account-level partner visibility updates for private, balance-only, and
8
+ balance-and-holdings sharing.
9
+ - Keep portfolio results strict, machine-readable, and explicit about refresh
10
+ status and excluded currencies.
11
+
3
12
  ## 0.19.0 - 2026-08-26
4
13
 
5
14
  - Make every Goal use one explicit personal Goal-funding account and expose
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, categorise transactions, and configure payment notifications through the
3
+ Use your own agent to inspect personal and household accounts, investments, and budgets, manage goals, 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
@@ -24,7 +24,7 @@ Create a personal access token in Sloth Money under
24
24
  **Settings > Developer access**, then choose the authentication method for
25
25
  where the CLI runs.
26
26
 
27
- New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
27
+ New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`, `portfolio`,
28
28
  `budget`, `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
29
29
  creating the token only if the CLI must apply assignments, manage categories
30
30
  or line items, move assigned budget money, update planned budgets, manage accounts, ask a partner for an explanation, or manage goals. Token
@@ -103,6 +103,7 @@ options, output, and examples. For example:
103
103
  ```bash
104
104
  sloth-agent auth login --help
105
105
  sloth-agent accounts --help
106
+ sloth-agent portfolio --help
106
107
  sloth-agent budget --help
107
108
  sloth-agent budget status --help
108
109
  sloth-agent budget update --help
@@ -584,7 +585,8 @@ sloth-agent accounts
584
585
  The command is read-only and cache-only: it does not refresh linked banks or
585
586
  change account data. Each result contains an opaque `accountRef`, personal or
586
587
  joint ownership, connected or manual source, native balance/currency when
587
- known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalFundingAccount`.
588
+ known, `lastBalanceUpdatedAt`, `connectionState`, `isGoalFundingAccount`, and
589
+ `partnerVisibility`.
588
590
  Missing values are JSON
589
591
  `null`; currencies are never converted or combined. Partner personal accounts
590
592
  are excluded, while enabled shared joint accounts follow Sloth's existing
@@ -604,7 +606,7 @@ Copy the value from `sloth-agent accounts`. Account references are the public
604
606
  account identifier for transaction filtering.
605
607
 
606
608
  Account changes are previews unless `--apply` is present. Connected accounts
607
- support only Goal-funding eligibility. Manual current accounts support their
609
+ support Goal-funding eligibility and partner visibility. Manual current accounts support their
608
610
  institution, name, currency, and ownership. Manual balance accounts also
609
611
  support balance, Savings/Investments type, and Goal-funding eligibility.
610
612
  Partner-owned shared accounts return an explanatory error.
@@ -618,14 +620,31 @@ sloth-agent accounts update \
618
620
  --ownership individual \
619
621
  --balance-amount 12500.75 \
620
622
  --account-type investments \
621
- --goal-funding-account false
623
+ --goal-funding-account false \
624
+ --partner-visibility holdings
622
625
 
623
626
  sloth-agent accounts update \
624
627
  --account-ref sloth_account_v1_... \
625
- --goal-funding-account false \
628
+ --partner-visibility balance \
626
629
  --apply
627
630
  ```
628
631
 
632
+ Read the same current position from your, your partner's, or the combined
633
+ household perspective:
634
+
635
+ ```bash
636
+ sloth-agent portfolio
637
+ sloth-agent portfolio --view partner
638
+ sloth-agent portfolio --view household
639
+ ```
640
+
641
+ The command waits up to 45 seconds for eligible linked balances to refresh,
642
+ then returns cached data if work continues. Partner accounts appear only when
643
+ their owner has shared the balance or linked holdings. Sharing is for household
644
+ planning only. It does not change account ownership, transaction access, Goal
645
+ funding, or who can move money. Totals use the viewer's budget currency and
646
+ exclude other native currencies without converting them.
647
+
629
648
  Archive an owned manual account. The account disappears from active Sloth
630
649
  surfaces, but its underlying records are retained. Repeating an applied removal
631
650
  is safe and returns `changed: false`.
package/dist/args.js CHANGED
@@ -390,6 +390,7 @@ function parseAccounts(args, baseUrl) {
390
390
  '--balance-amount',
391
391
  '--account-type',
392
392
  '--goal-funding-account',
393
+ '--partner-visibility',
393
394
  ]));
394
395
  const institutionName = values.get('--institution-name');
395
396
  const accountName = values.get('--account-name');
@@ -398,6 +399,7 @@ function parseAccounts(args, baseUrl) {
398
399
  const balanceValue = values.get('--balance-amount');
399
400
  const accountTypeValue = values.get('--account-type');
400
401
  const sourceValue = values.get('--goal-funding-account');
402
+ const partnerVisibility = values.get('--partner-visibility');
401
403
  if (currencyValue !== undefined && !/^[A-Za-z]{3}$/.test(currencyValue)) {
402
404
  throw new UsageError('--currency must be a three-letter currency code');
403
405
  }
@@ -414,6 +416,12 @@ function parseAccounts(args, baseUrl) {
414
416
  if (sourceValue !== undefined && sourceValue !== 'true' && sourceValue !== 'false') {
415
417
  throw new UsageError('--goal-funding-account must be true or false');
416
418
  }
419
+ if (partnerVisibility !== undefined
420
+ && partnerVisibility !== 'private'
421
+ && partnerVisibility !== 'balance'
422
+ && partnerVisibility !== 'holdings') {
423
+ throw new UsageError('--partner-visibility must be private, balance, or holdings');
424
+ }
417
425
  const update = {
418
426
  ...(institutionName === undefined
419
427
  ? {}
@@ -430,6 +438,9 @@ function parseAccounts(args, baseUrl) {
430
438
  ? {}
431
439
  : { accountType: accountTypeValue }),
432
440
  ...(sourceValue === undefined ? {} : { isGoalFundingAccount: sourceValue === 'true' }),
441
+ ...(partnerVisibility === undefined ? {} : {
442
+ partnerVisibility: partnerVisibility,
443
+ }),
433
444
  };
434
445
  if (Object.keys(update).length === 0) {
435
446
  throw new UsageError('accounts update requires at least one field to update');
@@ -471,6 +482,16 @@ function parseInvestments(args, baseUrl) {
471
482
  }
472
483
  return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
473
484
  }
485
+ function parsePortfolio(args, baseUrl) {
486
+ const { values, apply } = parseNamedOptions(args, 'portfolio', new Set(['--view']));
487
+ if (apply)
488
+ throw new UsageError('portfolio does not accept --apply');
489
+ const view = values.get('--view') ?? 'mine';
490
+ if (view !== 'mine' && view !== 'partner' && view !== 'household') {
491
+ throw new UsageError('--view must be mine, partner, or household');
492
+ }
493
+ return withBaseUrl({ command: 'portfolio', view }, baseUrl);
494
+ }
474
495
  function parseBudget(args, baseUrl) {
475
496
  const subcommand = args[0] === 'status' || args[0] === 'update' || args[0] === 'move'
476
497
  ? args.shift()
@@ -979,8 +1000,8 @@ function helpTopic(argv) {
979
1000
  return 'accounts-remove';
980
1001
  return command;
981
1002
  }
982
- if (command === 'investments')
983
- return 'investments';
1003
+ if (command === 'investments' || command === 'portfolio')
1004
+ return command;
984
1005
  return undefined;
985
1006
  }
986
1007
  export function parseArgs(argv) {
@@ -1015,6 +1036,9 @@ export function parseArgs(argv) {
1015
1036
  if (command === 'investments') {
1016
1037
  return parseInvestments(args, baseUrl);
1017
1038
  }
1039
+ if (command === 'portfolio') {
1040
+ return parsePortfolio(args, baseUrl);
1041
+ }
1018
1042
  if (command === 'budget') {
1019
1043
  return parseBudget(args, baseUrl);
1020
1044
  }
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { ICON_KEYS } from './category-metadata.js';
6
6
  import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
7
7
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
8
8
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
9
- export const CLI_VERSION = '0.19.0';
9
+ export const CLI_VERSION = '0.20.0';
10
10
  const REQUEST_TIMEOUT_MS = 60_000;
11
11
  const MAX_CONTRACT_PDF_BYTES = 6_000_000;
12
12
  const API_ORIGIN_HELP_LINES = [
@@ -34,6 +34,7 @@ export function usageText() {
34
34
  ' sloth-agent accounts update --account-ref REF [fields] [--apply]',
35
35
  ' sloth-agent accounts remove --account-ref REF [--apply]',
36
36
  ' sloth-agent investments [--account-ref REF] [--base-url URL]',
37
+ ' sloth-agent portfolio [--view mine|partner|household] [--base-url URL]',
37
38
  ' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
38
39
  ' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
39
40
  ' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
@@ -378,11 +379,14 @@ export function accountsUpdateHelpText() {
378
379
  ' --balance-amount AMOUNT Balance-only account balance.',
379
380
  ' --account-type savings|investments Balance-only account type.',
380
381
  ' --goal-funding-account true|false Whether Goals may use this account.',
382
+ ' --partner-visibility private|balance|holdings',
383
+ ' What this account shares with your partner.',
381
384
  '',
382
385
  'Write behavior:',
383
386
  ' Without --apply, returns a JSON preview without credentials or a network request.',
384
387
  ' With --apply, requires agent:write on a write-enabled token and updates saved Sloth metadata.',
385
- ' Connected accounts support only --goal-funding-account.',
388
+ ' Connected accounts support --goal-funding-account and --partner-visibility.',
389
+ ' Sharing exposes planning data only. It does not change ownership or assign the account to Goals.',
386
390
  ' Manual current accounts cannot change type, balance, or Goal-funding membership.',
387
391
  ' Partner-owned shared accounts cannot be changed.',
388
392
  ' Unknown, disconnected, or inaccessible references return Account not found.',
@@ -442,6 +446,33 @@ export function investmentsHelpText() {
442
446
  ' provider-native and are not converted or guaranteed to reconcile to totals.',
443
447
  ].join('\n');
444
448
  }
449
+ export function portfolioHelpText() {
450
+ return [
451
+ 'Sloth Agent CLI — portfolio',
452
+ '',
453
+ 'Read your savings and investments from one household planning perspective.',
454
+ '',
455
+ 'Usage:',
456
+ ' sloth-agent portfolio [--view mine|partner|household] [--base-url URL]',
457
+ '',
458
+ 'Options:',
459
+ ' --view mine|partner|household Optional. Defaults to mine.',
460
+ ' --base-url URL Optional. Override the API origin.',
461
+ ' -h, --help Show this help.',
462
+ ...API_ORIGIN_HELP_LINES,
463
+ '',
464
+ 'Access:',
465
+ ' This read-only command waits up to 45 seconds for eligible linked balances to refresh.',
466
+ ' Partner shows only balances or holdings your partner explicitly shared.',
467
+ ' Household combines your accounts with those shared balances and deduplicates joint accounts.',
468
+ ' Shared data supports planning only. It does not assign partner accounts to Goals or change ownership.',
469
+ '',
470
+ 'Output:',
471
+ ' totals gives savings, investments, and tracked amounts in the viewer currency.',
472
+ ' accounts includes ownerRole, freshness, sharing level, and permitted holdings.',
473
+ ' refresh reports whether eligible linked balances refreshed or cached data was returned.',
474
+ ].join('\n');
475
+ }
445
476
  export function budgetHelpText() {
446
477
  return [
447
478
  'Sloth Agent CLI — budget',
@@ -1173,6 +1204,7 @@ export function commandHelpText(topic) {
1173
1204
  'accounts-update': accountsUpdateHelpText,
1174
1205
  'accounts-remove': accountsRemoveHelpText,
1175
1206
  investments: investmentsHelpText,
1207
+ portfolio: portfolioHelpText,
1176
1208
  budget: budgetHelpText,
1177
1209
  'budget-status': budgetStatusHelpText,
1178
1210
  'budget-move': budgetMoveHelpText,
@@ -2063,19 +2095,21 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
2063
2095
  }
2064
2096
  const path = parsed.command === 'accounts'
2065
2097
  ? '/api/agent/v1/accounts'
2066
- : parsed.command === 'investments'
2067
- ? `/api/agent/v1/investments${parsed.accountRef
2068
- ? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
2069
- : ''}`
2070
- : parsed.command === 'categories'
2071
- ? '/api/agent/v1/categories'
2072
- : `/api/agent/v1/transactions${(() => {
2073
- const query = buildTransactionsQuery(parsed.filters);
2074
- return query ? `?${query}` : '';
2075
- })()}`;
2098
+ : parsed.command === 'portfolio'
2099
+ ? `/api/agent/v1/portfolio?${new URLSearchParams({ view: parsed.view }).toString()}`
2100
+ : parsed.command === 'investments'
2101
+ ? `/api/agent/v1/investments${parsed.accountRef
2102
+ ? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
2103
+ : ''}`
2104
+ : parsed.command === 'categories'
2105
+ ? '/api/agent/v1/categories'
2106
+ : `/api/agent/v1/transactions${(() => {
2107
+ const query = buildTransactionsQuery(parsed.filters);
2108
+ return query ? `?${query}` : '';
2109
+ })()}`;
2076
2110
  const response = await fetchImplementation(`${baseUrl}${path}`, {
2077
2111
  method: 'GET',
2078
- headers: parsed.command === 'transactions'
2112
+ headers: parsed.command === 'transactions' || parsed.command === 'portfolio'
2079
2113
  ? { ...headers, Prefer: 'wait=45' }
2080
2114
  : headers,
2081
2115
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
package/dist/contracts.js CHANGED
@@ -861,6 +861,7 @@ function isAccount(value) {
861
861
  'lastBalanceUpdatedAt',
862
862
  'connectionState',
863
863
  'isGoalFundingAccount',
864
+ 'partnerVisibility',
864
865
  ])
865
866
  && isAccountRef(value.accountRef)
866
867
  && isNullableNonEmptyString(value.accountName)
@@ -879,7 +880,10 @@ function isAccount(value) {
879
880
  || value.connectionState === 'expired'
880
881
  || value.connectionState === 'manual'
881
882
  || value.connectionState === 'unknown')
882
- && typeof value.isGoalFundingAccount === 'boolean');
883
+ && typeof value.isGoalFundingAccount === 'boolean'
884
+ && (value.partnerVisibility === 'private'
885
+ || value.partnerVisibility === 'balance'
886
+ || value.partnerVisibility === 'holdings'));
883
887
  }
884
888
  function isAccountsResponse(value) {
885
889
  return (isObject(value)
@@ -945,6 +949,55 @@ function isInvestmentsResponse(value) {
945
949
  && holdings.every(isInvestmentHolding));
946
950
  }));
947
951
  }
952
+ function isPortfolioAccount(value) {
953
+ return isObject(value)
954
+ && hasOnlyFields(value, [
955
+ 'accountRef', 'ownerRole', 'accountName', 'institutionName', 'accountType',
956
+ 'ownership', 'balanceAmount', 'currency', 'source', 'lastBalanceUpdatedAt',
957
+ 'connectionState', 'partnerVisibility', 'isGoalFundingAccount', 'holdings',
958
+ ])
959
+ && isAccountRef(value.accountRef)
960
+ && (value.ownerRole === 'you' || value.ownerRole === 'partner')
961
+ && isNullableNonEmptyString(value.accountName)
962
+ && isNullableNonEmptyString(value.institutionName)
963
+ && (value.accountType === 'savings' || value.accountType === 'investments')
964
+ && (value.ownership === 'personal' || value.ownership === 'joint')
965
+ && (value.balanceAmount === null || (typeof value.balanceAmount === 'number' && Number.isFinite(value.balanceAmount)))
966
+ && isCurrency(value.currency)
967
+ && (value.source === 'connected' || value.source === 'manual')
968
+ && (value.lastBalanceUpdatedAt === null || isIsoDateTime(value.lastBalanceUpdatedAt))
969
+ && ['active', 'expired', 'manual', 'unknown'].includes(String(value.connectionState))
970
+ && ['private', 'balance', 'holdings'].includes(String(value.partnerVisibility))
971
+ && (value.isGoalFundingAccount === null || typeof value.isGoalFundingAccount === 'boolean')
972
+ && Array.isArray(value.holdings)
973
+ && value.holdings.every(isInvestmentHolding);
974
+ }
975
+ function isPortfolioResponse(value) {
976
+ if (!isObject(value) || !hasOnlyFields(value, [
977
+ 'asOf', 'currency', 'view', 'hasPartner', 'totals',
978
+ 'excludedCurrencyAccountCount', 'accounts', 'refresh',
979
+ ]))
980
+ return false;
981
+ const totals = value.totals;
982
+ const refresh = value.refresh;
983
+ return isIsoDateTime(value.asOf)
984
+ && isCurrency(value.currency)
985
+ && ['mine', 'partner', 'household'].includes(String(value.view))
986
+ && typeof value.hasPartner === 'boolean'
987
+ && isObject(totals)
988
+ && hasOnlyFields(totals, ['savingsAmount', 'investmentsAmount', 'trackedAmount'])
989
+ && ['savingsAmount', 'investmentsAmount', 'trackedAmount'].every(field => (typeof totals[field] === 'number' && Number.isFinite(totals[field])))
990
+ && Number.isSafeInteger(value.excludedCurrencyAccountCount)
991
+ && Number(value.excludedCurrencyAccountCount) >= 0
992
+ && Array.isArray(value.accounts)
993
+ && value.accounts.every(isPortfolioAccount)
994
+ && isObject(refresh)
995
+ && hasOnlyFields(refresh, ['status', 'reason', 'utcDate'])
996
+ && ['skipped', 'completed', 'in_progress', 'partial', 'failed'].includes(String(refresh.status))
997
+ && typeof refresh.reason === 'string'
998
+ && refresh.reason.length > 0
999
+ && isIsoDate(refresh.utcDate);
1000
+ }
948
1001
  function isForecastBasis(value) {
949
1002
  return isObject(value)
950
1003
  && hasOnlyFields(value, [
@@ -1059,47 +1112,49 @@ export function parseApiResponse(command, value) {
1059
1112
  ? isAccountRemovalResponse(value)
1060
1113
  : command === 'investments'
1061
1114
  ? isInvestmentsResponse(value)
1062
- : command === 'budget' || command === 'budget-update'
1063
- ? isBudgetResponse(value)
1064
- : command === 'budget-status'
1065
- ? isBudgetActivityStatusResponse(value)
1066
- : command === 'budget-move'
1067
- ? isBudgetMovementResponse(value)
1068
- : command === 'categories'
1069
- ? isCategoryResponse(value)
1070
- : command === 'categories-create' || command === 'categories-rename'
1071
- ? isCategoryMutationResponse(value)
1072
- : command === 'line-items-create' || command === 'line-items-rename'
1073
- ? isLineItemMutationResponse(value)
1074
- : command === 'transactions'
1075
- ? isTransactionsResponse(value)
1076
- : command === 'rules-list'
1077
- ? isNotificationRuleListResponse(value)
1078
- : command === 'rules-get' || command === 'rules-set'
1079
- ? isNotificationRuleResponse(value)
1080
- : command === 'rules-delete'
1081
- ? isNotificationRuleDeleteResponse(value)
1082
- : command === 'rules-scan-contract'
1083
- ? isRenewalExtractionResponse(value)
1084
- : command === 'receipts-extract'
1085
- ? isReceiptExtractResponse(value)
1086
- : command === 'receipts-get'
1087
- ? isReceiptLookupResponse(value)
1088
- : command === 'receipts-attach'
1089
- ? isReceiptMutationResponse(value)
1090
- : command === 'receipts-remove'
1091
- ? isReceiptDeleteResponse(value)
1092
- : command === 'assign'
1093
- ? isAssignmentResponse(value)
1094
- : command === 'ask-partner'
1095
- ? isPartnerResponse(value)
1096
- : command === 'goals-list'
1097
- ? isGoalsResponse(value)
1098
- : command === 'goals-preview'
1099
- ? isGoalPreviewResponse(value)
1100
- : command === 'goals-delete'
1101
- ? isGoalDeleteResponse(value)
1102
- : isGoalMutationResponse(value);
1115
+ : command === 'portfolio'
1116
+ ? isPortfolioResponse(value)
1117
+ : command === 'budget' || command === 'budget-update'
1118
+ ? isBudgetResponse(value)
1119
+ : command === 'budget-status'
1120
+ ? isBudgetActivityStatusResponse(value)
1121
+ : command === 'budget-move'
1122
+ ? isBudgetMovementResponse(value)
1123
+ : command === 'categories'
1124
+ ? isCategoryResponse(value)
1125
+ : command === 'categories-create' || command === 'categories-rename'
1126
+ ? isCategoryMutationResponse(value)
1127
+ : command === 'line-items-create' || command === 'line-items-rename'
1128
+ ? isLineItemMutationResponse(value)
1129
+ : command === 'transactions'
1130
+ ? isTransactionsResponse(value)
1131
+ : command === 'rules-list'
1132
+ ? isNotificationRuleListResponse(value)
1133
+ : command === 'rules-get' || command === 'rules-set'
1134
+ ? isNotificationRuleResponse(value)
1135
+ : command === 'rules-delete'
1136
+ ? isNotificationRuleDeleteResponse(value)
1137
+ : command === 'rules-scan-contract'
1138
+ ? isRenewalExtractionResponse(value)
1139
+ : command === 'receipts-extract'
1140
+ ? isReceiptExtractResponse(value)
1141
+ : command === 'receipts-get'
1142
+ ? isReceiptLookupResponse(value)
1143
+ : command === 'receipts-attach'
1144
+ ? isReceiptMutationResponse(value)
1145
+ : command === 'receipts-remove'
1146
+ ? isReceiptDeleteResponse(value)
1147
+ : command === 'assign'
1148
+ ? isAssignmentResponse(value)
1149
+ : command === 'ask-partner'
1150
+ ? isPartnerResponse(value)
1151
+ : command === 'goals-list'
1152
+ ? isGoalsResponse(value)
1153
+ : command === 'goals-preview'
1154
+ ? isGoalPreviewResponse(value)
1155
+ : command === 'goals-delete'
1156
+ ? isGoalDeleteResponse(value)
1157
+ : isGoalMutationResponse(value);
1103
1158
  if (!valid) {
1104
1159
  const label = command === 'assign' ? 'assignment' : command;
1105
1160
  throw new ApiError(`Invalid ${label} response from the Agent API`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {