@slothmoney/agent-cli 0.5.0 → 0.6.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
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.0 - 2026-08-08
6
+
7
+ - Expose goal-savings membership on account inventory rows and preview or apply
8
+ owner-authorized changes through opaque account references.
9
+ - Read cache-only linked investment portfolios with provider-native holdings,
10
+ quantities, valuations, currencies, and freshness metadata.
11
+ - Keep strict response validation, JSON-only stdout, command-specific help,
12
+ and clean-install package coverage synchronized with Agent API v1.
13
+
5
14
  ## 0.5.0 - 2026-08-07
6
15
 
7
16
  - Create and rename custom categories, with existing icon and category type
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect known accounts, manage goals, and categorise transactions through the
3
+ Use your own agent to inspect accounts and investment holdings, manage goals, and categorise transactions 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.5.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.6.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -24,10 +24,10 @@ Create a personal access token in Sloth Money under
24
24
  **Settings > Developer access**, then choose the authentication method for
25
25
  where the CLI runs.
26
26
 
27
- New tokens are view-only. That is enough for `auth status`, `accounts`,
27
+ New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
28
28
  `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
29
29
  creating the token only if the CLI must apply assignments, manage categories
30
- or line items, ask a partner for an explanation, or manage goals. Token
30
+ or line items, change goal-savings account membership, ask a partner for an explanation, or manage goals. Token
31
31
  permissions cannot be changed later - revoke and reissue the token instead.
32
32
 
33
33
  ### Local computer
@@ -250,11 +250,41 @@ sloth-agent accounts
250
250
  The command is read-only and cache-only: it does not refresh linked banks or
251
251
  change account data. Each result contains an opaque `accountRef`, personal or
252
252
  joint ownership, connected or manual source, native balance/currency when
253
- known, `lastBalanceUpdatedAt`, and `connectionState`. Missing values are JSON
253
+ known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalSavingsSource`.
254
+ Missing values are JSON
254
255
  `null`; currencies are never converted or combined. Partner personal accounts
255
256
  are excluded, while enabled shared joint accounts follow Sloth's existing
256
257
  visibility rules.
257
258
 
259
+ Goal-savings changes are previews unless `--apply` is present. Only
260
+ caller-owned connected accounts can be changed; partner-owned shared accounts
261
+ and fixed manual accounts return an explanatory error.
262
+
263
+ ```bash
264
+ sloth-agent accounts update \
265
+ --account-ref sloth_account_v1_... \
266
+ --goal-savings-source true
267
+
268
+ sloth-agent accounts update \
269
+ --account-ref sloth_account_v1_... \
270
+ --goal-savings-source true \
271
+ --apply
272
+ ```
273
+
274
+ Read linked investment accounts and their cached holdings:
275
+
276
+ ```bash
277
+ sloth-agent investments
278
+ sloth-agent investments --account-ref sloth_account_v1_...
279
+ ```
280
+
281
+ Investment reads are cache-only and do not refresh a brokerage. Holding
282
+ quantities, unit prices, market values, currencies, and freshness are returned
283
+ in provider-native terms. They are not converted or guaranteed to reconcile
284
+ to an account total reported in another currency. Caller-owned personal and
285
+ joint linked investment accounts are included; partner-owned accounts, manual
286
+ holdings, and investment activities are not.
287
+
258
288
  List your goals:
259
289
 
260
290
  ```bash
package/dist/args.js CHANGED
@@ -226,6 +226,54 @@ function requiredOption(values, option, commandLabel) {
226
226
  throw new UsageError(`${commandLabel} requires ${option} <value>`);
227
227
  return value;
228
228
  }
229
+ function parseAccountRef(value) {
230
+ if (!/^sloth_account_v1_[A-Za-z0-9_-]{43}$/.test(value)) {
231
+ throw new UsageError('--account-ref must be a valid accountRef from sloth-agent accounts');
232
+ }
233
+ return value;
234
+ }
235
+ function parseAccounts(args, baseUrl) {
236
+ const subcommand = args.shift();
237
+ if (subcommand === undefined || subcommand === 'list') {
238
+ if (args.length > 0)
239
+ throw new UsageError(`Unknown accounts option: ${args[0]}`);
240
+ return withBaseUrl({ command: 'accounts' }, baseUrl);
241
+ }
242
+ if (subcommand === 'update') {
243
+ const { values, apply } = parseNamedOptions(args, 'accounts update', new Set(['--account-ref', '--goal-savings-source']));
244
+ const source = requiredOption(values, '--goal-savings-source', 'accounts update');
245
+ if (source !== 'true' && source !== 'false') {
246
+ throw new UsageError('--goal-savings-source must be true or false');
247
+ }
248
+ return withBaseUrl({
249
+ command: 'accounts-update',
250
+ accountRef: parseAccountRef(requiredOption(values, '--account-ref', 'accounts update')),
251
+ isGoalSavingsSource: source === 'true',
252
+ apply,
253
+ }, baseUrl);
254
+ }
255
+ if (subcommand.startsWith('-')) {
256
+ throw new UsageError(`Unknown accounts option: ${subcommand}`);
257
+ }
258
+ throw new UsageError(`Unknown accounts command: ${subcommand}`);
259
+ }
260
+ function parseInvestments(args, baseUrl) {
261
+ let accountRef;
262
+ for (let index = 0; index < args.length; index += 1) {
263
+ const argument = args[index];
264
+ if (argument === '--account-ref') {
265
+ accountRef = setOnce(accountRef, parseAccountRef(readOptionValue(args, index, '--account-ref')), '--account-ref');
266
+ index += 1;
267
+ }
268
+ else if (argument.startsWith('--account-ref=')) {
269
+ accountRef = setOnce(accountRef, parseAccountRef(requireNonEmpty(argument.slice('--account-ref='.length), '--account-ref')), '--account-ref');
270
+ }
271
+ else {
272
+ throw new UsageError(`Unknown investments option: ${argument}`);
273
+ }
274
+ }
275
+ return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
276
+ }
229
277
  function parseCategories(args, baseUrl) {
230
278
  const subcommand = args.shift();
231
279
  if (subcommand === undefined || subcommand === 'list') {
@@ -555,8 +603,12 @@ function helpTopic(argv) {
555
603
  || command === 'transactions'
556
604
  || command === 'assign'
557
605
  || command === 'ask-partner') {
606
+ if (command === 'accounts' && subcommand === 'update')
607
+ return 'accounts-update';
558
608
  return command;
559
609
  }
610
+ if (command === 'investments')
611
+ return 'investments';
560
612
  return undefined;
561
613
  }
562
614
  export function parseArgs(argv) {
@@ -583,10 +635,10 @@ export function parseArgs(argv) {
583
635
  return parseLineItems(args, baseUrl);
584
636
  }
585
637
  if (command === 'accounts') {
586
- if (args.length > 0) {
587
- throw new UsageError(`Unknown accounts option: ${args[0]}`);
588
- }
589
- return withBaseUrl({ command }, baseUrl);
638
+ return parseAccounts(args, baseUrl);
639
+ }
640
+ if (command === 'investments') {
641
+ return parseInvestments(args, baseUrl);
590
642
  }
591
643
  if (command === 'transactions') {
592
644
  return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { ICON_KEYS } from './category-metadata.js';
4
4
  import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
5
5
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
6
6
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
7
- export const CLI_VERSION = '0.5.0';
7
+ export const CLI_VERSION = '0.6.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -25,7 +25,9 @@ export function usageText() {
25
25
  ' sloth-agent auth login [--token-stdin | --from-env] [--base-url URL]',
26
26
  ' sloth-agent auth status [--base-url URL]',
27
27
  ' sloth-agent auth logout [--base-url URL]',
28
- ' sloth-agent accounts [--base-url URL]',
28
+ ' sloth-agent accounts [list] [--base-url URL]',
29
+ ' sloth-agent accounts update --account-ref REF --goal-savings-source true|false [--apply]',
30
+ ' sloth-agent investments [--account-ref REF] [--base-url URL]',
29
31
  ' sloth-agent categories [list] [--base-url URL]',
30
32
  ' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
31
33
  ' sloth-agent categories rename --category-id ID --name NAME [--apply]',
@@ -280,7 +282,7 @@ export function accountsHelpText() {
280
282
  'Read the existing Sloth account inventory known to the authenticated user.',
281
283
  '',
282
284
  'Usage:',
283
- ' sloth-agent accounts [--base-url URL]',
285
+ ' sloth-agent accounts [list] [--base-url URL]',
284
286
  '',
285
287
  'Options:',
286
288
  ' --base-url URL Optional. Override the API origin.',
@@ -296,6 +298,57 @@ export function accountsHelpText() {
296
298
  ' accounts[].ownership personal or joint',
297
299
  ' accounts[].balanceAmount and currency in the native currency when known',
298
300
  ' accounts[].connectionState and lastBalanceUpdatedAt for freshness',
301
+ ' accounts[].isGoalSavingsSource whether the owner uses it for goal savings',
302
+ ].join('\n');
303
+ }
304
+ export function accountsUpdateHelpText() {
305
+ return [
306
+ 'Sloth Agent CLI — accounts update',
307
+ '',
308
+ 'Preview or update whether an owned connected account is used for goal savings.',
309
+ '',
310
+ 'Usage:',
311
+ ' sloth-agent accounts update --account-ref REF --goal-savings-source true|false [--apply] [--base-url URL]',
312
+ '',
313
+ 'Required inputs:',
314
+ ' --account-ref REF Opaque accountRef from sloth-agent accounts.',
315
+ ' --goal-savings-source true|false Enable or disable goal-savings membership.',
316
+ '',
317
+ 'Write behavior:',
318
+ ' Without --apply, returns a JSON preview without credentials or a network request.',
319
+ ' With --apply, requires agent:write on a write-enabled token and updates saved Sloth metadata.',
320
+ ' Partner-owned shared accounts and manual accounts cannot be changed.',
321
+ ' Unknown, disconnected, or inaccessible references return Account not found.',
322
+ ...API_ORIGIN_HELP_LINES,
323
+ '',
324
+ 'Output:',
325
+ ' Preview mode returns dryRun, method, endpoint, and payload.',
326
+ ' Apply mode returns changed and the complete persisted account.',
327
+ ].join('\n');
328
+ }
329
+ export function investmentsHelpText() {
330
+ return [
331
+ 'Sloth Agent CLI — investments',
332
+ '',
333
+ 'Read linked investment accounts and their cached provider-native holdings.',
334
+ '',
335
+ 'Usage:',
336
+ ' sloth-agent investments [--account-ref REF] [--base-url URL]',
337
+ '',
338
+ 'Options:',
339
+ ' --account-ref REF Optional. Return one linked investment account.',
340
+ ' --base-url URL Optional. Override the API origin.',
341
+ ' -h, --help Show this help.',
342
+ ...API_ORIGIN_HELP_LINES,
343
+ '',
344
+ 'Access:',
345
+ ' This command requires agent:read and is read-only and cache-only; it never refreshes a brokerage.',
346
+ ' An unknown or non-investment filter returns Investment account not found.',
347
+ '',
348
+ 'Output:',
349
+ ' investmentAccounts contains account totals and nested holdings.',
350
+ ' Holding quantities, prices, market values, currencies, and freshness are',
351
+ ' provider-native and are not converted or guaranteed to reconcile to totals.',
299
352
  ].join('\n');
300
353
  }
301
354
  export function transactionsHelpText() {
@@ -581,6 +634,8 @@ export function commandHelpText(topic) {
581
634
  'auth-status': authStatusHelpText,
582
635
  'auth-logout': authLogoutHelpText,
583
636
  accounts: accountsHelpText,
637
+ 'accounts-update': accountsUpdateHelpText,
638
+ investments: investmentsHelpText,
584
639
  categories: categoriesHelpText,
585
640
  'categories-create': categoriesCreateHelpText,
586
641
  'categories-rename': categoriesRenameHelpText,
@@ -833,9 +888,32 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
833
888
  });
834
889
  return 0;
835
890
  }
891
+ if (parsed.command === 'accounts-update' && !parsed.apply) {
892
+ const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
893
+ writeJson(writeStdout, {
894
+ dryRun: true,
895
+ endpoint,
896
+ method: 'PATCH',
897
+ payload: { isGoalSavingsSource: parsed.isGoalSavingsSource },
898
+ });
899
+ return 0;
900
+ }
836
901
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
837
902
  token = credential.token;
838
903
  const headers = requestHeaders(token);
904
+ if (parsed.command === 'accounts-update') {
905
+ const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
906
+ const payload = { isGoalSavingsSource: parsed.isGoalSavingsSource };
907
+ const response = await fetchImplementation(endpoint, {
908
+ method: 'PATCH',
909
+ headers: { ...headers, 'Content-Type': 'application/json' },
910
+ body: JSON.stringify(payload),
911
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
912
+ });
913
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
914
+ writeJson(writeStdout, data);
915
+ return 0;
916
+ }
839
917
  if (parsed.command === 'categories-create'
840
918
  || parsed.command === 'categories-rename'
841
919
  || parsed.command === 'line-items-create'
@@ -1004,12 +1082,16 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1004
1082
  }
1005
1083
  const path = parsed.command === 'accounts'
1006
1084
  ? '/api/agent/v1/accounts'
1007
- : parsed.command === 'categories'
1008
- ? '/api/agent/v1/categories'
1009
- : `/api/agent/v1/transactions${(() => {
1010
- const query = buildTransactionsQuery(parsed.filters);
1011
- return query ? `?${query}` : '';
1012
- })()}`;
1085
+ : parsed.command === 'investments'
1086
+ ? `/api/agent/v1/investments${parsed.accountRef
1087
+ ? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
1088
+ : ''}`
1089
+ : parsed.command === 'categories'
1090
+ ? '/api/agent/v1/categories'
1091
+ : `/api/agent/v1/transactions${(() => {
1092
+ const query = buildTransactionsQuery(parsed.filters);
1093
+ return query ? `?${query}` : '';
1094
+ })()}`;
1013
1095
  const response = await fetchImplementation(`${baseUrl}${path}`, {
1014
1096
  method: 'GET',
1015
1097
  headers: parsed.command === 'transactions'
package/dist/contracts.js CHANGED
@@ -303,6 +303,7 @@ function isAccount(value) {
303
303
  'source',
304
304
  'lastBalanceUpdatedAt',
305
305
  'connectionState',
306
+ 'isGoalSavingsSource',
306
307
  ])
307
308
  && typeof value.accountRef === 'string'
308
309
  && /^sloth_account_v1_[A-Za-z0-9_-]{43}$/.test(value.accountRef)
@@ -321,7 +322,8 @@ function isAccount(value) {
321
322
  && (value.connectionState === 'active'
322
323
  || value.connectionState === 'expired'
323
324
  || value.connectionState === 'manual'
324
- || value.connectionState === 'unknown'));
325
+ || value.connectionState === 'unknown')
326
+ && typeof value.isGoalSavingsSource === 'boolean');
325
327
  }
326
328
  function isAccountsResponse(value) {
327
329
  return (isObject(value)
@@ -330,6 +332,56 @@ function isAccountsResponse(value) {
330
332
  && Array.isArray(value.accounts)
331
333
  && value.accounts.every(isAccount));
332
334
  }
335
+ function isAccountMutationResponse(value) {
336
+ return (isObject(value)
337
+ && hasOnlyFields(value, ['changed', 'account'])
338
+ && typeof value.changed === 'boolean'
339
+ && isAccount(value.account));
340
+ }
341
+ function isInvestmentHolding(value) {
342
+ return (isObject(value)
343
+ && hasOnlyFields(value, [
344
+ 'instrumentType',
345
+ 'symbol',
346
+ 'name',
347
+ 'units',
348
+ 'unitPriceAmount',
349
+ 'marketValueAmount',
350
+ 'currency',
351
+ 'providerFreshnessAsOf',
352
+ 'syncedAt',
353
+ ])
354
+ && typeof value.instrumentType === 'string'
355
+ && value.instrumentType.trim().length > 0
356
+ && (value.symbol === null || (typeof value.symbol === 'string' && value.symbol.trim().length > 0))
357
+ && typeof value.name === 'string'
358
+ && value.name.trim().length > 0
359
+ && typeof value.units === 'number'
360
+ && Number.isFinite(value.units)
361
+ && typeof value.unitPriceAmount === 'number'
362
+ && Number.isFinite(value.unitPriceAmount)
363
+ && typeof value.marketValueAmount === 'number'
364
+ && Number.isFinite(value.marketValueAmount)
365
+ && isCurrency(value.currency)
366
+ && (value.providerFreshnessAsOf === null || isIsoDateTime(value.providerFreshnessAsOf))
367
+ && isIsoDateTime(value.syncedAt));
368
+ }
369
+ function isInvestmentsResponse(value) {
370
+ return (isObject(value)
371
+ && hasOnlyFields(value, ['asOf', 'investmentAccounts'])
372
+ && isIsoDateTime(value.asOf)
373
+ && Array.isArray(value.investmentAccounts)
374
+ && value.investmentAccounts.every((account) => {
375
+ if (!isObject(account))
376
+ return false;
377
+ const { holdings, ...baseAccount } = account;
378
+ return (isAccount(baseAccount)
379
+ && account.accountType === 'investments'
380
+ && account.source === 'connected'
381
+ && Array.isArray(holdings)
382
+ && holdings.every(isInvestmentHolding));
383
+ }));
384
+ }
333
385
  function isGoalsResponse(value) {
334
386
  return (isObject(value)
335
387
  && hasOnlyFields(value, ['currency', 'goals'])
@@ -353,23 +405,27 @@ function isGoalDeleteResponse(value) {
353
405
  export function parseApiResponse(command, value) {
354
406
  const valid = command === 'accounts'
355
407
  ? isAccountsResponse(value)
356
- : command === 'categories'
357
- ? isCategoryResponse(value)
358
- : command === 'categories-create' || command === 'categories-rename'
359
- ? isCategoryMutationResponse(value)
360
- : command === 'line-items-create' || command === 'line-items-rename'
361
- ? isLineItemMutationResponse(value)
362
- : command === 'transactions'
363
- ? isTransactionsResponse(value)
364
- : command === 'assign'
365
- ? isAssignmentResponse(value)
366
- : command === 'ask-partner'
367
- ? isPartnerResponse(value)
368
- : command === 'goals-list'
369
- ? isGoalsResponse(value)
370
- : command === 'goals-delete'
371
- ? isGoalDeleteResponse(value)
372
- : isGoalMutationResponse(value);
408
+ : command === 'accounts-update'
409
+ ? isAccountMutationResponse(value)
410
+ : command === 'investments'
411
+ ? isInvestmentsResponse(value)
412
+ : command === 'categories'
413
+ ? isCategoryResponse(value)
414
+ : command === 'categories-create' || command === 'categories-rename'
415
+ ? isCategoryMutationResponse(value)
416
+ : command === 'line-items-create' || command === 'line-items-rename'
417
+ ? isLineItemMutationResponse(value)
418
+ : command === 'transactions'
419
+ ? isTransactionsResponse(value)
420
+ : command === 'assign'
421
+ ? isAssignmentResponse(value)
422
+ : command === 'ask-partner'
423
+ ? isPartnerResponse(value)
424
+ : command === 'goals-list'
425
+ ? isGoalsResponse(value)
426
+ : command === 'goals-delete'
427
+ ? isGoalDeleteResponse(value)
428
+ : isGoalMutationResponse(value);
373
429
  if (!valid) {
374
430
  const label = command === 'assign' ? 'assignment' : command;
375
431
  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.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {