@slothmoney/agent-cli 0.6.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 CHANGED
@@ -2,6 +2,11 @@
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
+
5
10
  ## 0.6.0 - 2026-08-08
6
11
 
7
12
  - Expose goal-savings membership on account inventory rows and preview or apply
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect accounts and investment holdings, manage goals, and categorise transactions through the
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.6.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.7.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -25,9 +25,9 @@ Create a personal access token in Sloth Money under
25
25
  where the CLI runs.
26
26
 
27
27
  New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
28
- `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
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, change goal-savings account membership, 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
 
package/dist/args.js CHANGED
@@ -274,6 +274,31 @@ function parseInvestments(args, baseUrl) {
274
274
  }
275
275
  return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
276
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
+ }
277
302
  function parseCategories(args, baseUrl) {
278
303
  const subcommand = args.shift();
279
304
  if (subcommand === undefined || subcommand === 'list') {
@@ -599,6 +624,9 @@ function helpTopic(argv) {
599
624
  return 'line-items-rename';
600
625
  return undefined;
601
626
  }
627
+ if (command === 'budget') {
628
+ return subcommand === 'update' ? 'budget-update' : 'budget';
629
+ }
602
630
  if (command === 'accounts'
603
631
  || command === 'transactions'
604
632
  || command === 'assign'
@@ -640,6 +668,9 @@ export function parseArgs(argv) {
640
668
  if (command === 'investments') {
641
669
  return parseInvestments(args, baseUrl);
642
670
  }
671
+ if (command === 'budget') {
672
+ return parseBudget(args, baseUrl);
673
+ }
643
674
  if (command === 'transactions') {
644
675
  return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
645
676
  }
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.6.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
  '',
@@ -28,6 +28,9 @@ export function usageText() {
28
28
  ' sloth-agent accounts [list] [--base-url URL]',
29
29
  ' sloth-agent accounts update --account-ref REF --goal-savings-source true|false [--apply]',
30
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]',
31
34
  ' sloth-agent categories [list] [--base-url URL]',
32
35
  ' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
33
36
  ' sloth-agent categories rename --category-id ID --name NAME [--apply]',
@@ -351,6 +354,70 @@ export function investmentsHelpText() {
351
354
  ' provider-native and are not converted or guaranteed to reconcile to totals.',
352
355
  ].join('\n');
353
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.',
419
+ ].join('\n');
420
+ }
354
421
  export function transactionsHelpText() {
355
422
  return [
356
423
  'Sloth Agent CLI — transactions',
@@ -636,6 +703,8 @@ export function commandHelpText(topic) {
636
703
  accounts: accountsHelpText,
637
704
  'accounts-update': accountsUpdateHelpText,
638
705
  investments: investmentsHelpText,
706
+ budget: budgetHelpText,
707
+ 'budget-update': budgetUpdateHelpText,
639
708
  categories: categoriesHelpText,
640
709
  'categories-create': categoriesCreateHelpText,
641
710
  'categories-rename': categoriesRenameHelpText,
@@ -721,6 +790,15 @@ function readAssignmentFile(filePath) {
721
790
  throw new UsageError(`Failed to read assignment JSON: ${message}`);
722
791
  }
723
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
+ }
724
802
  function buildTransactionsQuery(filters) {
725
803
  const params = new URLSearchParams();
726
804
  if (filters.uncategorized !== undefined) {
@@ -898,6 +976,22 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
898
976
  });
899
977
  return 0;
900
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
+ }
901
995
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
902
996
  token = credential.token;
903
997
  const headers = requestHeaders(token);
@@ -914,6 +1008,22 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
914
1008
  writeJson(writeStdout, data);
915
1009
  return 0;
916
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
+ }
917
1027
  if (parsed.command === 'categories-create'
918
1028
  || parsed.command === 'categories-rename'
919
1029
  || parsed.command === 'line-items-create'
@@ -1080,6 +1190,19 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1080
1190
  writeJson(writeStdout, data);
1081
1191
  return 0;
1082
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
+ }
1083
1206
  const path = parsed.command === 'accounts'
1084
1207
  ? '/api/agent/v1/accounts'
1085
1208
  : parsed.command === 'investments'
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
@@ -409,23 +494,25 @@ export function parseApiResponse(command, value) {
409
494
  ? isAccountMutationResponse(value)
410
495
  : command === 'investments'
411
496
  ? 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);
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);
429
516
  if (!valid) {
430
517
  const label = command === 'assign' ? 'assignment' : command;
431
518
  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.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {