@slothmoney/agent-cli 0.5.0 → 0.7.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 +14 -0
- package/README.md +85 -6
- package/dist/args.js +87 -4
- package/dist/cli.js +215 -10
- package/dist/contracts.js +161 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Read personal or joint budget periods with categories, line items, funding,
|
|
6
|
+
and planned amounts.
|
|
7
|
+
- Preview or apply planned line-item updates that overwrite the selected period
|
|
8
|
+
and all explicit future plans.
|
|
9
|
+
|
|
10
|
+
## 0.6.0 - 2026-08-08
|
|
11
|
+
|
|
12
|
+
- Expose goal-savings membership on account inventory rows and preview or apply
|
|
13
|
+
owner-authorized changes through opaque account references.
|
|
14
|
+
- Read cache-only linked investment portfolios with provider-native holdings,
|
|
15
|
+
quantities, valuations, currencies, and freshness metadata.
|
|
16
|
+
- Keep strict response validation, JSON-only stdout, command-specific help,
|
|
17
|
+
and clean-install package coverage synchronized with Agent API v1.
|
|
18
|
+
|
|
5
19
|
## 0.5.0 - 2026-08-07
|
|
6
20
|
|
|
7
21
|
- 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
|
|
3
|
+
Use your own agent to inspect accounts, investments, and budgets, manage goals, update planned amounts, 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.
|
|
18
|
+
npm exec --yes --package=@slothmoney/agent-cli@0.7.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`,
|
|
28
|
-
`categories`, `transactions`, and `goals` list. Enable **Allow changes** when
|
|
27
|
+
New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
|
|
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
|
-
or line items, ask a partner for an explanation, or manage goals. Token
|
|
30
|
+
or line items, update planned budgets, 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
|
|
@@ -103,6 +103,8 @@ options, output, and examples:
|
|
|
103
103
|
```bash
|
|
104
104
|
sloth-agent auth login --help
|
|
105
105
|
sloth-agent accounts --help
|
|
106
|
+
sloth-agent budget --help
|
|
107
|
+
sloth-agent budget update --help
|
|
106
108
|
sloth-agent categories --help
|
|
107
109
|
sloth-agent categories create --help
|
|
108
110
|
sloth-agent line-items create --help
|
|
@@ -184,6 +186,53 @@ query. Assignments do not create a separate list.
|
|
|
184
186
|
|
|
185
187
|
### Other workflows
|
|
186
188
|
|
|
189
|
+
Read a personal or joint budget. Omit `--period` to use Sloth's current budget period:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
sloth-agent budget --scope personal --period 2026-08
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The result includes the budget period and status, currency, the effective plan,
|
|
196
|
+
stored funding amounts when available, categories, line items, and planned
|
|
197
|
+
amounts in pence.
|
|
198
|
+
|
|
199
|
+
Update selected line-item amounts by creating `budget.json`:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"allocations": [
|
|
204
|
+
{
|
|
205
|
+
"categoryId": "groceries",
|
|
206
|
+
"lineItemId": "weekly",
|
|
207
|
+
"plannedPence": 45000
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Preview locally, then apply the same file:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
sloth-agent budget update \
|
|
217
|
+
--scope personal \
|
|
218
|
+
--period 2026-08 \
|
|
219
|
+
--input budget.json
|
|
220
|
+
|
|
221
|
+
sloth-agent budget update \
|
|
222
|
+
--scope personal \
|
|
223
|
+
--period 2026-08 \
|
|
224
|
+
--input budget.json \
|
|
225
|
+
--apply
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The update starts from the complete selected-period budget, changes the listed
|
|
229
|
+
line items, then overwrites the selected period and every explicit future plan
|
|
230
|
+
with that complete result. A later update from another period overwrites that
|
|
231
|
+
period and everything after it. Earlier and historical periods remain unchanged.
|
|
232
|
+
|
|
233
|
+
Without `--apply`, the CLI validates the file locally and does not load a token
|
|
234
|
+
or contact Sloth Money. Applying requires a write-enabled token.
|
|
235
|
+
|
|
187
236
|
Create or rename a custom category. Writes are previews until `--apply` is
|
|
188
237
|
present:
|
|
189
238
|
|
|
@@ -250,11 +299,41 @@ sloth-agent accounts
|
|
|
250
299
|
The command is read-only and cache-only: it does not refresh linked banks or
|
|
251
300
|
change account data. Each result contains an opaque `accountRef`, personal or
|
|
252
301
|
joint ownership, connected or manual source, native balance/currency when
|
|
253
|
-
known, `lastBalanceUpdatedAt`, and `
|
|
302
|
+
known, `lastBalanceUpdatedAt`, `connectionState`, and `isGoalSavingsSource`.
|
|
303
|
+
Missing values are JSON
|
|
254
304
|
`null`; currencies are never converted or combined. Partner personal accounts
|
|
255
305
|
are excluded, while enabled shared joint accounts follow Sloth's existing
|
|
256
306
|
visibility rules.
|
|
257
307
|
|
|
308
|
+
Goal-savings changes are previews unless `--apply` is present. Only
|
|
309
|
+
caller-owned connected accounts can be changed; partner-owned shared accounts
|
|
310
|
+
and fixed manual accounts return an explanatory error.
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
sloth-agent accounts update \
|
|
314
|
+
--account-ref sloth_account_v1_... \
|
|
315
|
+
--goal-savings-source true
|
|
316
|
+
|
|
317
|
+
sloth-agent accounts update \
|
|
318
|
+
--account-ref sloth_account_v1_... \
|
|
319
|
+
--goal-savings-source true \
|
|
320
|
+
--apply
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Read linked investment accounts and their cached holdings:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
sloth-agent investments
|
|
327
|
+
sloth-agent investments --account-ref sloth_account_v1_...
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Investment reads are cache-only and do not refresh a brokerage. Holding
|
|
331
|
+
quantities, unit prices, market values, currencies, and freshness are returned
|
|
332
|
+
in provider-native terms. They are not converted or guaranteed to reconcile
|
|
333
|
+
to an account total reported in another currency. Caller-owned personal and
|
|
334
|
+
joint linked investment accounts are included; partner-owned accounts, manual
|
|
335
|
+
holdings, and investment activities are not.
|
|
336
|
+
|
|
258
337
|
List your goals:
|
|
259
338
|
|
|
260
339
|
```bash
|
package/dist/args.js
CHANGED
|
@@ -226,6 +226,79 @@ 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
|
+
}
|
|
277
|
+
function parseBudget(args, baseUrl) {
|
|
278
|
+
const update = args[0] === 'update';
|
|
279
|
+
if (update)
|
|
280
|
+
args.shift();
|
|
281
|
+
const { values, apply } = parseNamedOptions(args, update ? 'budget update' : 'budget', new Set(update ? ['--scope', '--period', '--input'] : ['--scope', '--period']));
|
|
282
|
+
if (!update && apply)
|
|
283
|
+
throw new UsageError('Unknown budget option: --apply');
|
|
284
|
+
const scope = requiredOption(values, '--scope', update ? 'budget update' : 'budget');
|
|
285
|
+
if (scope !== 'personal' && scope !== 'joint') {
|
|
286
|
+
throw new UsageError('--scope must be personal or joint');
|
|
287
|
+
}
|
|
288
|
+
const period = values.get('--period');
|
|
289
|
+
const common = {
|
|
290
|
+
scope: scope,
|
|
291
|
+
...(period === undefined ? {} : { periodKey: parseGoalMonthKey(period, '--period') }),
|
|
292
|
+
};
|
|
293
|
+
if (!update)
|
|
294
|
+
return withBaseUrl({ command: 'budget', ...common }, baseUrl);
|
|
295
|
+
return withBaseUrl({
|
|
296
|
+
command: 'budget-update',
|
|
297
|
+
...common,
|
|
298
|
+
input: requiredOption(values, '--input', 'budget update'),
|
|
299
|
+
apply,
|
|
300
|
+
}, baseUrl);
|
|
301
|
+
}
|
|
229
302
|
function parseCategories(args, baseUrl) {
|
|
230
303
|
const subcommand = args.shift();
|
|
231
304
|
if (subcommand === undefined || subcommand === 'list') {
|
|
@@ -551,12 +624,19 @@ function helpTopic(argv) {
|
|
|
551
624
|
return 'line-items-rename';
|
|
552
625
|
return undefined;
|
|
553
626
|
}
|
|
627
|
+
if (command === 'budget') {
|
|
628
|
+
return subcommand === 'update' ? 'budget-update' : 'budget';
|
|
629
|
+
}
|
|
554
630
|
if (command === 'accounts'
|
|
555
631
|
|| command === 'transactions'
|
|
556
632
|
|| command === 'assign'
|
|
557
633
|
|| command === 'ask-partner') {
|
|
634
|
+
if (command === 'accounts' && subcommand === 'update')
|
|
635
|
+
return 'accounts-update';
|
|
558
636
|
return command;
|
|
559
637
|
}
|
|
638
|
+
if (command === 'investments')
|
|
639
|
+
return 'investments';
|
|
560
640
|
return undefined;
|
|
561
641
|
}
|
|
562
642
|
export function parseArgs(argv) {
|
|
@@ -583,10 +663,13 @@ export function parseArgs(argv) {
|
|
|
583
663
|
return parseLineItems(args, baseUrl);
|
|
584
664
|
}
|
|
585
665
|
if (command === 'accounts') {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
return
|
|
666
|
+
return parseAccounts(args, baseUrl);
|
|
667
|
+
}
|
|
668
|
+
if (command === 'investments') {
|
|
669
|
+
return parseInvestments(args, baseUrl);
|
|
670
|
+
}
|
|
671
|
+
if (command === 'budget') {
|
|
672
|
+
return parseBudget(args, baseUrl);
|
|
590
673
|
}
|
|
591
674
|
if (command === 'transactions') {
|
|
592
675
|
return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { parseArgs, resolveBaseUrl, } from './args.js';
|
|
3
3
|
import { ICON_KEYS } from './category-metadata.js';
|
|
4
|
-
import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
|
|
4
|
+
import { parseApiResponse, validateAssignmentPayload, validateBudgetUpdatePayload, } 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.
|
|
7
|
+
export const CLI_VERSION = '0.7.0';
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
9
9
|
const API_ORIGIN_HELP_LINES = [
|
|
10
10
|
'',
|
|
@@ -25,7 +25,12 @@ 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]',
|
|
31
|
+
' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
32
|
+
' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
|
|
33
|
+
' --input budget.json [--apply] [--base-url URL]',
|
|
29
34
|
' sloth-agent categories [list] [--base-url URL]',
|
|
30
35
|
' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
|
|
31
36
|
' sloth-agent categories rename --category-id ID --name NAME [--apply]',
|
|
@@ -280,7 +285,7 @@ export function accountsHelpText() {
|
|
|
280
285
|
'Read the existing Sloth account inventory known to the authenticated user.',
|
|
281
286
|
'',
|
|
282
287
|
'Usage:',
|
|
283
|
-
' sloth-agent accounts [--base-url URL]',
|
|
288
|
+
' sloth-agent accounts [list] [--base-url URL]',
|
|
284
289
|
'',
|
|
285
290
|
'Options:',
|
|
286
291
|
' --base-url URL Optional. Override the API origin.',
|
|
@@ -296,6 +301,121 @@ export function accountsHelpText() {
|
|
|
296
301
|
' accounts[].ownership personal or joint',
|
|
297
302
|
' accounts[].balanceAmount and currency in the native currency when known',
|
|
298
303
|
' accounts[].connectionState and lastBalanceUpdatedAt for freshness',
|
|
304
|
+
' accounts[].isGoalSavingsSource whether the owner uses it for goal savings',
|
|
305
|
+
].join('\n');
|
|
306
|
+
}
|
|
307
|
+
export function accountsUpdateHelpText() {
|
|
308
|
+
return [
|
|
309
|
+
'Sloth Agent CLI — accounts update',
|
|
310
|
+
'',
|
|
311
|
+
'Preview or update whether an owned connected account is used for goal savings.',
|
|
312
|
+
'',
|
|
313
|
+
'Usage:',
|
|
314
|
+
' sloth-agent accounts update --account-ref REF --goal-savings-source true|false [--apply] [--base-url URL]',
|
|
315
|
+
'',
|
|
316
|
+
'Required inputs:',
|
|
317
|
+
' --account-ref REF Opaque accountRef from sloth-agent accounts.',
|
|
318
|
+
' --goal-savings-source true|false Enable or disable goal-savings membership.',
|
|
319
|
+
'',
|
|
320
|
+
'Write behavior:',
|
|
321
|
+
' Without --apply, returns a JSON preview without credentials or a network request.',
|
|
322
|
+
' With --apply, requires agent:write on a write-enabled token and updates saved Sloth metadata.',
|
|
323
|
+
' Partner-owned shared accounts and manual accounts cannot be changed.',
|
|
324
|
+
' Unknown, disconnected, or inaccessible references return Account not found.',
|
|
325
|
+
...API_ORIGIN_HELP_LINES,
|
|
326
|
+
'',
|
|
327
|
+
'Output:',
|
|
328
|
+
' Preview mode returns dryRun, method, endpoint, and payload.',
|
|
329
|
+
' Apply mode returns changed and the complete persisted account.',
|
|
330
|
+
].join('\n');
|
|
331
|
+
}
|
|
332
|
+
export function investmentsHelpText() {
|
|
333
|
+
return [
|
|
334
|
+
'Sloth Agent CLI — investments',
|
|
335
|
+
'',
|
|
336
|
+
'Read linked investment accounts and their cached provider-native holdings.',
|
|
337
|
+
'',
|
|
338
|
+
'Usage:',
|
|
339
|
+
' sloth-agent investments [--account-ref REF] [--base-url URL]',
|
|
340
|
+
'',
|
|
341
|
+
'Options:',
|
|
342
|
+
' --account-ref REF Optional. Return one linked investment account.',
|
|
343
|
+
' --base-url URL Optional. Override the API origin.',
|
|
344
|
+
' -h, --help Show this help.',
|
|
345
|
+
...API_ORIGIN_HELP_LINES,
|
|
346
|
+
'',
|
|
347
|
+
'Access:',
|
|
348
|
+
' This command requires agent:read and is read-only and cache-only; it never refreshes a brokerage.',
|
|
349
|
+
' An unknown or non-investment filter returns Investment account not found.',
|
|
350
|
+
'',
|
|
351
|
+
'Output:',
|
|
352
|
+
' investmentAccounts contains account totals and nested holdings.',
|
|
353
|
+
' Holding quantities, prices, market values, currencies, and freshness are',
|
|
354
|
+
' provider-native and are not converted or guaranteed to reconcile to totals.',
|
|
355
|
+
].join('\n');
|
|
356
|
+
}
|
|
357
|
+
export function budgetHelpText() {
|
|
358
|
+
return [
|
|
359
|
+
'Sloth Agent CLI — budget',
|
|
360
|
+
'',
|
|
361
|
+
'Read one personal or joint budget period.',
|
|
362
|
+
'',
|
|
363
|
+
'Usage:',
|
|
364
|
+
' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
365
|
+
'',
|
|
366
|
+
'Options:',
|
|
367
|
+
' --scope personal|joint Required. Budget ownership scope.',
|
|
368
|
+
' --period YYYY-MM Optional. Defaults to the current Sloth budget period.',
|
|
369
|
+
' --base-url URL Optional. Override the API origin.',
|
|
370
|
+
' -h, --help Show this help.',
|
|
371
|
+
...API_ORIGIN_HELP_LINES,
|
|
372
|
+
'',
|
|
373
|
+
'Access:',
|
|
374
|
+
' This command is read-only and requires agent:read.',
|
|
375
|
+
'',
|
|
376
|
+
'Output:',
|
|
377
|
+
' JSON containing scope, periodKey, periodStatus, currency, and effectiveFromPeriodKey.',
|
|
378
|
+
' funding contains current stored to-assign and reserve amounts when that period exists.',
|
|
379
|
+
' categories[].lineItems contains line-item IDs, names, and planned amounts in pence.',
|
|
380
|
+
' Categories also include plannedPence and assignedPence.',
|
|
381
|
+
].join('\n');
|
|
382
|
+
}
|
|
383
|
+
export function budgetUpdateHelpText() {
|
|
384
|
+
return [
|
|
385
|
+
'Sloth Agent CLI — budget update',
|
|
386
|
+
'',
|
|
387
|
+
'Preview or update planned line-item amounts for one budget scope.',
|
|
388
|
+
'',
|
|
389
|
+
'Usage:',
|
|
390
|
+
' sloth-agent budget update --scope personal|joint [--period YYYY-MM] --input FILE [--apply] [--base-url URL]',
|
|
391
|
+
'',
|
|
392
|
+
'Required inputs:',
|
|
393
|
+
' --scope personal|joint Budget ownership scope.',
|
|
394
|
+
' --input FILE JSON file containing allocations.',
|
|
395
|
+
'',
|
|
396
|
+
'Optional inputs:',
|
|
397
|
+
' --period YYYY-MM Defaults to the current Sloth budget period.',
|
|
398
|
+
' --apply Send the update. Without it, only validate and preview.',
|
|
399
|
+
' --base-url URL Override the API origin.',
|
|
400
|
+
' -h, --help Show this help.',
|
|
401
|
+
...API_ORIGIN_HELP_LINES,
|
|
402
|
+
'',
|
|
403
|
+
'Input format:',
|
|
404
|
+
' {"allocations":[{"categoryId":"groceries","lineItemId":"weekly","plannedPence":45000}]}',
|
|
405
|
+
' Provide 1 to 100 unique categoryId and lineItemId pairs.',
|
|
406
|
+
' plannedPence must be a nonnegative whole number of pence.',
|
|
407
|
+
'',
|
|
408
|
+
'Write behavior:',
|
|
409
|
+
' Without --apply, returns JSON after local validation and does not load credentials',
|
|
410
|
+
' or contact Sloth Money. A successful preview does not guarantee the remote write.',
|
|
411
|
+
' With --apply, each supplied amount patches a complete selected-period budget.',
|
|
412
|
+
' The resulting complete budget overwrites the selected period and every explicit future plan.',
|
|
413
|
+
' A later update from another period overwrites that period and everything after it.',
|
|
414
|
+
' Historical periods cannot be changed. Applying requires agent:write.',
|
|
415
|
+
'',
|
|
416
|
+
'Output:',
|
|
417
|
+
' Preview mode returns dryRun, endpoint, method, and the validated payload.',
|
|
418
|
+
' Apply mode returns the complete persisted budget response.',
|
|
299
419
|
].join('\n');
|
|
300
420
|
}
|
|
301
421
|
export function transactionsHelpText() {
|
|
@@ -581,6 +701,10 @@ export function commandHelpText(topic) {
|
|
|
581
701
|
'auth-status': authStatusHelpText,
|
|
582
702
|
'auth-logout': authLogoutHelpText,
|
|
583
703
|
accounts: accountsHelpText,
|
|
704
|
+
'accounts-update': accountsUpdateHelpText,
|
|
705
|
+
investments: investmentsHelpText,
|
|
706
|
+
budget: budgetHelpText,
|
|
707
|
+
'budget-update': budgetUpdateHelpText,
|
|
584
708
|
categories: categoriesHelpText,
|
|
585
709
|
'categories-create': categoriesCreateHelpText,
|
|
586
710
|
'categories-rename': categoriesRenameHelpText,
|
|
@@ -666,6 +790,15 @@ function readAssignmentFile(filePath) {
|
|
|
666
790
|
throw new UsageError(`Failed to read assignment JSON: ${message}`);
|
|
667
791
|
}
|
|
668
792
|
}
|
|
793
|
+
function readBudgetFile(filePath) {
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
799
|
+
throw new UsageError(`Failed to read budget JSON: ${message}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
669
802
|
function buildTransactionsQuery(filters) {
|
|
670
803
|
const params = new URLSearchParams();
|
|
671
804
|
if (filters.uncategorized !== undefined) {
|
|
@@ -833,9 +966,64 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
833
966
|
});
|
|
834
967
|
return 0;
|
|
835
968
|
}
|
|
969
|
+
if (parsed.command === 'accounts-update' && !parsed.apply) {
|
|
970
|
+
const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
|
|
971
|
+
writeJson(writeStdout, {
|
|
972
|
+
dryRun: true,
|
|
973
|
+
endpoint,
|
|
974
|
+
method: 'PATCH',
|
|
975
|
+
payload: { isGoalSavingsSource: parsed.isGoalSavingsSource },
|
|
976
|
+
});
|
|
977
|
+
return 0;
|
|
978
|
+
}
|
|
979
|
+
const budgetUpdatePayload = parsed.command === 'budget-update'
|
|
980
|
+
? validateBudgetUpdatePayload(readBudgetFile(parsed.input))
|
|
981
|
+
: undefined;
|
|
982
|
+
if (parsed.command === 'budget-update' && !parsed.apply) {
|
|
983
|
+
writeJson(writeStdout, {
|
|
984
|
+
dryRun: true,
|
|
985
|
+
endpoint: `${baseUrl}/api/agent/v1/budgets`,
|
|
986
|
+
method: 'PATCH',
|
|
987
|
+
payload: {
|
|
988
|
+
scope: parsed.scope,
|
|
989
|
+
...(parsed.periodKey === undefined ? {} : { periodKey: parsed.periodKey }),
|
|
990
|
+
...budgetUpdatePayload,
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
return 0;
|
|
994
|
+
}
|
|
836
995
|
const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
|
|
837
996
|
token = credential.token;
|
|
838
997
|
const headers = requestHeaders(token);
|
|
998
|
+
if (parsed.command === 'accounts-update') {
|
|
999
|
+
const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
|
|
1000
|
+
const payload = { isGoalSavingsSource: parsed.isGoalSavingsSource };
|
|
1001
|
+
const response = await fetchImplementation(endpoint, {
|
|
1002
|
+
method: 'PATCH',
|
|
1003
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1004
|
+
body: JSON.stringify(payload),
|
|
1005
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1006
|
+
});
|
|
1007
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1008
|
+
writeJson(writeStdout, data);
|
|
1009
|
+
return 0;
|
|
1010
|
+
}
|
|
1011
|
+
if (parsed.command === 'budget-update') {
|
|
1012
|
+
const payload = {
|
|
1013
|
+
scope: parsed.scope,
|
|
1014
|
+
...(parsed.periodKey === undefined ? {} : { periodKey: parsed.periodKey }),
|
|
1015
|
+
...budgetUpdatePayload,
|
|
1016
|
+
};
|
|
1017
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budgets`, {
|
|
1018
|
+
method: 'PATCH',
|
|
1019
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1020
|
+
body: JSON.stringify(payload),
|
|
1021
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1022
|
+
});
|
|
1023
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1024
|
+
writeJson(writeStdout, data);
|
|
1025
|
+
return 0;
|
|
1026
|
+
}
|
|
839
1027
|
if (parsed.command === 'categories-create'
|
|
840
1028
|
|| parsed.command === 'categories-rename'
|
|
841
1029
|
|| parsed.command === 'line-items-create'
|
|
@@ -1002,14 +1190,31 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1002
1190
|
writeJson(writeStdout, data);
|
|
1003
1191
|
return 0;
|
|
1004
1192
|
}
|
|
1193
|
+
if (parsed.command === 'budget') {
|
|
1194
|
+
const query = new URLSearchParams({ scope: parsed.scope });
|
|
1195
|
+
if (parsed.periodKey !== undefined)
|
|
1196
|
+
query.set('periodKey', parsed.periodKey);
|
|
1197
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budgets?${query.toString()}`, {
|
|
1198
|
+
method: 'GET',
|
|
1199
|
+
headers,
|
|
1200
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1201
|
+
});
|
|
1202
|
+
const data = parseApiResponse('budget', await parseHttpResponse(response, token));
|
|
1203
|
+
writeJson(writeStdout, data);
|
|
1204
|
+
return 0;
|
|
1205
|
+
}
|
|
1005
1206
|
const path = parsed.command === 'accounts'
|
|
1006
1207
|
? '/api/agent/v1/accounts'
|
|
1007
|
-
: parsed.command === '
|
|
1008
|
-
?
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1208
|
+
: parsed.command === 'investments'
|
|
1209
|
+
? `/api/agent/v1/investments${parsed.accountRef
|
|
1210
|
+
? `?${new URLSearchParams({ accountRef: parsed.accountRef }).toString()}`
|
|
1211
|
+
: ''}`
|
|
1212
|
+
: parsed.command === 'categories'
|
|
1213
|
+
? '/api/agent/v1/categories'
|
|
1214
|
+
: `/api/agent/v1/transactions${(() => {
|
|
1215
|
+
const query = buildTransactionsQuery(parsed.filters);
|
|
1216
|
+
return query ? `?${query}` : '';
|
|
1217
|
+
})()}`;
|
|
1013
1218
|
const response = await fetchImplementation(`${baseUrl}${path}`, {
|
|
1014
1219
|
method: 'GET',
|
|
1015
1220
|
headers: parsed.command === 'transactions'
|
package/dist/contracts.js
CHANGED
|
@@ -109,6 +109,36 @@ export function validateAssignmentPayload(value) {
|
|
|
109
109
|
}
|
|
110
110
|
return { assignments: payload.assignments.map(validateAssignment) };
|
|
111
111
|
}
|
|
112
|
+
export function validateBudgetUpdatePayload(value) {
|
|
113
|
+
const payload = requireObject(value, 'budget update payload');
|
|
114
|
+
rejectUnknownFields(payload, new Set(['allocations']), 'budget update payload');
|
|
115
|
+
if (!Array.isArray(payload.allocations)) {
|
|
116
|
+
throw new UsageError('allocations array is required');
|
|
117
|
+
}
|
|
118
|
+
if (payload.allocations.length < 1 || payload.allocations.length > 100) {
|
|
119
|
+
throw new UsageError('allocations must contain between 1 and 100 items');
|
|
120
|
+
}
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const allocations = payload.allocations.map((value, index) => {
|
|
123
|
+
const label = `allocations[${index}]`;
|
|
124
|
+
const allocation = requireObject(value, label);
|
|
125
|
+
rejectUnknownFields(allocation, new Set(['categoryId', 'lineItemId', 'plannedPence']), label);
|
|
126
|
+
const categoryId = requireString(allocation.categoryId, `${label}.categoryId`);
|
|
127
|
+
const lineItemId = requireString(allocation.lineItemId, `${label}.lineItemId`);
|
|
128
|
+
if (typeof allocation.plannedPence !== 'number'
|
|
129
|
+
|| !Number.isSafeInteger(allocation.plannedPence)
|
|
130
|
+
|| allocation.plannedPence < 0) {
|
|
131
|
+
throw new UsageError(`${label}.plannedPence must be a nonnegative safe integer`);
|
|
132
|
+
}
|
|
133
|
+
const key = `${categoryId}\u0000${lineItemId}`;
|
|
134
|
+
if (seen.has(key)) {
|
|
135
|
+
throw new UsageError(`${label} duplicates a categoryId and lineItemId pair`);
|
|
136
|
+
}
|
|
137
|
+
seen.add(key);
|
|
138
|
+
return { categoryId, lineItemId, plannedPence: allocation.plannedPence };
|
|
139
|
+
});
|
|
140
|
+
return { allocations };
|
|
141
|
+
}
|
|
112
142
|
function isLineItemMap(value) {
|
|
113
143
|
if (!isObject(value))
|
|
114
144
|
return false;
|
|
@@ -285,6 +315,61 @@ function isGoal(value) {
|
|
|
285
315
|
function isCurrency(value) {
|
|
286
316
|
return typeof value === 'string' && /^[A-Z]{3}$/.test(value);
|
|
287
317
|
}
|
|
318
|
+
function isNonnegativeSafeInteger(value) {
|
|
319
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
320
|
+
}
|
|
321
|
+
function isSafeInteger(value) {
|
|
322
|
+
return typeof value === 'number' && Number.isSafeInteger(value);
|
|
323
|
+
}
|
|
324
|
+
function isBudgetLineItem(value) {
|
|
325
|
+
return (isObject(value)
|
|
326
|
+
&& hasOnlyFields(value, ['id', 'name', 'plannedPence'])
|
|
327
|
+
&& typeof value.id === 'string'
|
|
328
|
+
&& value.id.trim().length > 0
|
|
329
|
+
&& typeof value.name === 'string'
|
|
330
|
+
&& value.name.trim().length > 0
|
|
331
|
+
&& isNonnegativeSafeInteger(value.plannedPence));
|
|
332
|
+
}
|
|
333
|
+
function isBudgetCategory(value) {
|
|
334
|
+
return (isObject(value)
|
|
335
|
+
&& hasOnlyFields(value, ['id', 'name', 'plannedPence', 'assignedPence', 'lineItems'])
|
|
336
|
+
&& typeof value.id === 'string'
|
|
337
|
+
&& value.id.trim().length > 0
|
|
338
|
+
&& typeof value.name === 'string'
|
|
339
|
+
&& value.name.trim().length > 0
|
|
340
|
+
&& isNonnegativeSafeInteger(value.plannedPence)
|
|
341
|
+
&& (value.assignedPence === null || isSafeInteger(value.assignedPence))
|
|
342
|
+
&& Array.isArray(value.lineItems)
|
|
343
|
+
&& value.lineItems.every(isBudgetLineItem));
|
|
344
|
+
}
|
|
345
|
+
function isBudgetResponse(value) {
|
|
346
|
+
return (isObject(value)
|
|
347
|
+
&& hasOnlyFields(value, [
|
|
348
|
+
'scope',
|
|
349
|
+
'periodKey',
|
|
350
|
+
'periodStatus',
|
|
351
|
+
'currency',
|
|
352
|
+
'effectiveFromPeriodKey',
|
|
353
|
+
'funding',
|
|
354
|
+
'categories',
|
|
355
|
+
])
|
|
356
|
+
&& (value.scope === 'personal' || value.scope === 'joint')
|
|
357
|
+
&& typeof value.periodKey === 'string'
|
|
358
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
|
|
359
|
+
&& (value.periodStatus === 'historical'
|
|
360
|
+
|| value.periodStatus === 'current'
|
|
361
|
+
|| value.periodStatus === 'future')
|
|
362
|
+
&& isCurrency(value.currency)
|
|
363
|
+
&& typeof value.effectiveFromPeriodKey === 'string'
|
|
364
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
|
|
365
|
+
&& (value.funding === null
|
|
366
|
+
|| (isObject(value.funding)
|
|
367
|
+
&& hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
|
|
368
|
+
&& isSafeInteger(value.funding.toAssignPence)
|
|
369
|
+
&& isSafeInteger(value.funding.nextPeriodReservePence)))
|
|
370
|
+
&& Array.isArray(value.categories)
|
|
371
|
+
&& value.categories.every(isBudgetCategory));
|
|
372
|
+
}
|
|
288
373
|
function isNullableNonEmptyString(value) {
|
|
289
374
|
return value === null || (typeof value === 'string'
|
|
290
375
|
&& value.length > 0
|
|
@@ -303,6 +388,7 @@ function isAccount(value) {
|
|
|
303
388
|
'source',
|
|
304
389
|
'lastBalanceUpdatedAt',
|
|
305
390
|
'connectionState',
|
|
391
|
+
'isGoalSavingsSource',
|
|
306
392
|
])
|
|
307
393
|
&& typeof value.accountRef === 'string'
|
|
308
394
|
&& /^sloth_account_v1_[A-Za-z0-9_-]{43}$/.test(value.accountRef)
|
|
@@ -321,7 +407,8 @@ function isAccount(value) {
|
|
|
321
407
|
&& (value.connectionState === 'active'
|
|
322
408
|
|| value.connectionState === 'expired'
|
|
323
409
|
|| value.connectionState === 'manual'
|
|
324
|
-
|| value.connectionState === 'unknown')
|
|
410
|
+
|| value.connectionState === 'unknown')
|
|
411
|
+
&& typeof value.isGoalSavingsSource === 'boolean');
|
|
325
412
|
}
|
|
326
413
|
function isAccountsResponse(value) {
|
|
327
414
|
return (isObject(value)
|
|
@@ -330,6 +417,56 @@ function isAccountsResponse(value) {
|
|
|
330
417
|
&& Array.isArray(value.accounts)
|
|
331
418
|
&& value.accounts.every(isAccount));
|
|
332
419
|
}
|
|
420
|
+
function isAccountMutationResponse(value) {
|
|
421
|
+
return (isObject(value)
|
|
422
|
+
&& hasOnlyFields(value, ['changed', 'account'])
|
|
423
|
+
&& typeof value.changed === 'boolean'
|
|
424
|
+
&& isAccount(value.account));
|
|
425
|
+
}
|
|
426
|
+
function isInvestmentHolding(value) {
|
|
427
|
+
return (isObject(value)
|
|
428
|
+
&& hasOnlyFields(value, [
|
|
429
|
+
'instrumentType',
|
|
430
|
+
'symbol',
|
|
431
|
+
'name',
|
|
432
|
+
'units',
|
|
433
|
+
'unitPriceAmount',
|
|
434
|
+
'marketValueAmount',
|
|
435
|
+
'currency',
|
|
436
|
+
'providerFreshnessAsOf',
|
|
437
|
+
'syncedAt',
|
|
438
|
+
])
|
|
439
|
+
&& typeof value.instrumentType === 'string'
|
|
440
|
+
&& value.instrumentType.trim().length > 0
|
|
441
|
+
&& (value.symbol === null || (typeof value.symbol === 'string' && value.symbol.trim().length > 0))
|
|
442
|
+
&& typeof value.name === 'string'
|
|
443
|
+
&& value.name.trim().length > 0
|
|
444
|
+
&& typeof value.units === 'number'
|
|
445
|
+
&& Number.isFinite(value.units)
|
|
446
|
+
&& typeof value.unitPriceAmount === 'number'
|
|
447
|
+
&& Number.isFinite(value.unitPriceAmount)
|
|
448
|
+
&& typeof value.marketValueAmount === 'number'
|
|
449
|
+
&& Number.isFinite(value.marketValueAmount)
|
|
450
|
+
&& isCurrency(value.currency)
|
|
451
|
+
&& (value.providerFreshnessAsOf === null || isIsoDateTime(value.providerFreshnessAsOf))
|
|
452
|
+
&& isIsoDateTime(value.syncedAt));
|
|
453
|
+
}
|
|
454
|
+
function isInvestmentsResponse(value) {
|
|
455
|
+
return (isObject(value)
|
|
456
|
+
&& hasOnlyFields(value, ['asOf', 'investmentAccounts'])
|
|
457
|
+
&& isIsoDateTime(value.asOf)
|
|
458
|
+
&& Array.isArray(value.investmentAccounts)
|
|
459
|
+
&& value.investmentAccounts.every((account) => {
|
|
460
|
+
if (!isObject(account))
|
|
461
|
+
return false;
|
|
462
|
+
const { holdings, ...baseAccount } = account;
|
|
463
|
+
return (isAccount(baseAccount)
|
|
464
|
+
&& account.accountType === 'investments'
|
|
465
|
+
&& account.source === 'connected'
|
|
466
|
+
&& Array.isArray(holdings)
|
|
467
|
+
&& holdings.every(isInvestmentHolding));
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
333
470
|
function isGoalsResponse(value) {
|
|
334
471
|
return (isObject(value)
|
|
335
472
|
&& hasOnlyFields(value, ['currency', 'goals'])
|
|
@@ -353,23 +490,29 @@ function isGoalDeleteResponse(value) {
|
|
|
353
490
|
export function parseApiResponse(command, value) {
|
|
354
491
|
const valid = command === 'accounts'
|
|
355
492
|
? isAccountsResponse(value)
|
|
356
|
-
: command === '
|
|
357
|
-
?
|
|
358
|
-
: command === '
|
|
359
|
-
?
|
|
360
|
-
: command === '
|
|
361
|
-
?
|
|
362
|
-
: command === '
|
|
363
|
-
?
|
|
364
|
-
: command === '
|
|
365
|
-
?
|
|
366
|
-
: command === '
|
|
367
|
-
?
|
|
368
|
-
: command === '
|
|
369
|
-
?
|
|
370
|
-
: command === '
|
|
371
|
-
?
|
|
372
|
-
:
|
|
493
|
+
: command === 'accounts-update'
|
|
494
|
+
? isAccountMutationResponse(value)
|
|
495
|
+
: command === 'investments'
|
|
496
|
+
? isInvestmentsResponse(value)
|
|
497
|
+
: command === 'budget' || command === 'budget-update'
|
|
498
|
+
? isBudgetResponse(value)
|
|
499
|
+
: command === 'categories'
|
|
500
|
+
? isCategoryResponse(value)
|
|
501
|
+
: command === 'categories-create' || command === 'categories-rename'
|
|
502
|
+
? isCategoryMutationResponse(value)
|
|
503
|
+
: command === 'line-items-create' || command === 'line-items-rename'
|
|
504
|
+
? isLineItemMutationResponse(value)
|
|
505
|
+
: command === 'transactions'
|
|
506
|
+
? isTransactionsResponse(value)
|
|
507
|
+
: command === 'assign'
|
|
508
|
+
? isAssignmentResponse(value)
|
|
509
|
+
: command === 'ask-partner'
|
|
510
|
+
? isPartnerResponse(value)
|
|
511
|
+
: command === 'goals-list'
|
|
512
|
+
? isGoalsResponse(value)
|
|
513
|
+
: command === 'goals-delete'
|
|
514
|
+
? isGoalDeleteResponse(value)
|
|
515
|
+
: isGoalMutationResponse(value);
|
|
373
516
|
if (!valid) {
|
|
374
517
|
const label = command === 'assign' ? 'assignment' : command;
|
|
375
518
|
throw new ApiError(`Invalid ${label} response from the Agent API`);
|