@slothmoney/agent-cli 0.12.0 → 0.13.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 - 2026-08-19
4
+
5
+ - Let `assign` share or unshare an owned booked personal transaction, update
6
+ its ratio and exclusive amounts in pence, and combine sharing with category
7
+ assignment in one atomic item.
8
+ - Add `transactions --shared[=true|false]` and strict validation for persisted
9
+ sharing results, including the resulting joint-budget contribution.
10
+ - Keep preview local and credential-free while printing the exact payload that
11
+ apply mode will send.
12
+
3
13
  ## 0.12.0 - 2026-08-16
4
14
 
5
15
  - Add read-only `budget status` for current-period assigned, spent, and
package/README.md CHANGED
@@ -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.12.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.13.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -94,8 +94,8 @@ remotely in **Sloth Money Settings > Developer access**.
94
94
 
95
95
  ## Commands
96
96
 
97
- An assignment categorises an existing transaction e.g. assigning category
98
- Groceries to a transaction.
97
+ An assignment can change an owned transaction's sharing, categorisation, or
98
+ both.
99
99
 
100
100
  Every command has built-in reference documentation covering its inputs,
101
101
  options, output, and examples:
@@ -145,7 +145,8 @@ item is already categorised for the joint budget. For example:
145
145
 
146
146
  This transaction is uncategorised personally but categorised as Groceries for
147
147
  the joint budget. The `--uncategorized` filter applies to the selected
148
- assignment scope; personal is used when `--assignment-scope` is omitted.
148
+ assignment scope; the transaction's native scope is used when
149
+ `--assignment-scope` is omitted.
149
150
 
150
151
  ### Categorise a transaction end to end
151
152
 
@@ -238,6 +239,71 @@ The transaction should also disappear from the matching `--uncategorized`
238
239
  query. Confirm that an existing assignment in the other scope was not changed.
239
240
  Assignments do not create a separate list.
240
241
 
242
+ ### Share and categorise a transaction
243
+
244
+ Find an owned, unshared booked transaction:
245
+
246
+ ```bash
247
+ sloth-agent transactions --shared=false --q "sainsbury" --limit 20
248
+ ```
249
+
250
+ Copy its exact `transactionRef` into `assignments.json`. This example shares
251
+ the transaction 60/40, keeps £5 for you personally, and categorises the shared
252
+ remainder as Groceries in Joint:
253
+
254
+ ```json
255
+ {
256
+ "assignments": [
257
+ {
258
+ "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",
259
+ "sharing": {
260
+ "isShared": true,
261
+ "shareRatio": 0.6,
262
+ "userExclusiveAmountPence": 500,
263
+ "partnerExclusiveAmountPence": 0
264
+ },
265
+ "assignmentScope": "joint",
266
+ "categoryId": "groceries"
267
+ }
268
+ ]
269
+ }
270
+ ```
271
+
272
+ Preview stays local and does not load credentials or call the API:
273
+
274
+ ```bash
275
+ sloth-agent assign --input assignments.json
276
+ ```
277
+
278
+ Apply with a token created using **Allow changes**, then read back the same
279
+ state shown in the Web App:
280
+
281
+ ```bash
282
+ sloth-agent assign --input assignments.json --apply
283
+ sloth-agent transactions --shared=true --q "sainsbury" --limit 20
284
+ ```
285
+
286
+ When `sharing` contains only `"isShared": true`, a first share uses the
287
+ couple's saved ratio, falling back to `0.5`, and shares the full amount. On an
288
+ already shared transaction, omitted split fields preserve their current values.
289
+ Set both exclusive pence fields to zero to share the full amount again.
290
+
291
+ To unshare, send `"sharing": { "isShared": false }` without ratio or exclusive
292
+ fields. Sloth clears the active split but keeps the Joint category dormant, so
293
+ sharing it again restores that category. Current-period Joint pay income is
294
+ reconciled; interest and completed periods keep their existing behaviour.
295
+
296
+ Sharing is available only for your booked personal-account transactions when
297
+ you have an active partner and Joint budget. Partner-owned rows and native
298
+ joint-account rows cannot be changed this way. Foreign-currency rows can still
299
+ be shared for settlement, but their returned contribution has `eligible: false`
300
+ and `included: false`.
301
+
302
+ If a combined item omits `assignmentScope`, the category uses Joint when you
303
+ have no exclusive amount and Personal when you do. Category-only items retain
304
+ their existing Personal/native default. Each item commits atomically, while a
305
+ bulk request remains best-effort across items.
306
+
241
307
  ### Other workflows
242
308
 
243
309
  Read a personal or joint budget. Omit `--period` to use Sloth's current budget period:
package/dist/args.js CHANGED
@@ -155,6 +155,18 @@ function parseTransactions(args) {
155
155
  filters.uncategorized = setOnce(filters.uncategorized, value === 'true', '--uncategorized');
156
156
  continue;
157
157
  }
158
+ if (argument === '--shared') {
159
+ filters.shared = setOnce(filters.shared, true, '--shared');
160
+ continue;
161
+ }
162
+ if (argument.startsWith('--shared=')) {
163
+ const value = argument.slice('--shared='.length);
164
+ if (value !== 'true' && value !== 'false') {
165
+ throw new UsageError('--shared must be true or false');
166
+ }
167
+ filters.shared = setOnce(filters.shared, value === 'true', '--shared');
168
+ continue;
169
+ }
158
170
  const [name, inlineValue] = argument.includes('=')
159
171
  ? argument.split(/=(.*)/s, 2)
160
172
  : [argument, undefined];
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { ICON_KEYS } from './category-metadata.js';
4
4
  import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, 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.12.0';
7
+ export const CLI_VERSION = '0.13.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -40,7 +40,7 @@ export function usageText() {
40
40
  ' sloth-agent categories rename --category-id ID --name NAME [--apply]',
41
41
  ' sloth-agent line-items create --scope personal|joint --category-id ID --name NAME [--apply]',
42
42
  ' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply]',
43
- ' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
43
+ ' sloth-agent transactions [--uncategorized[=true|false]] [--shared[=true|false]] [--limit N]',
44
44
  ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
45
45
  ' [--account-id ID] [--category-id ID] [--line-item-id ID]',
46
46
  ' [--cursor CURSOR] [--base-url URL]',
@@ -542,6 +542,7 @@ export function transactionsHelpText() {
542
542
  'Options:',
543
543
  ' --uncategorized[=true|false] Optional. Filter the selected assignment scope by state;',
544
544
  ' with no value, use true.',
545
+ ' --shared[=true|false] Optional. Filter by partner-sharing state; with no value, use true.',
545
546
  ' --limit N Optional. Integer from 1 to 200; omit for API default.',
546
547
  ' --start-date YYYY-MM-DD Optional. Include transactions on or after this date.',
547
548
  ' --end-date YYYY-MM-DD Optional. Include transactions on or before this date.',
@@ -550,7 +551,7 @@ export function transactionsHelpText() {
550
551
  ' --category-id ID Optional. Filter by category ID.',
551
552
  ' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
552
553
  ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
553
- ' Personal is used when omitted.',
554
+ ' The transaction\'s native scope is used when omitted.',
554
555
  ' --cursor CURSOR Optional. Continue from a previous nextCursor.',
555
556
  ' --base-url URL Optional. Override the API origin.',
556
557
  ' -h, --help Show this help.',
@@ -583,8 +584,7 @@ export function assignHelpText() {
583
584
  return [
584
585
  'Sloth Agent CLI — assign',
585
586
  '',
586
- 'An assignment categorises an existing transaction e.g. assigning category Groceries to a transaction.',
587
- 'Validate, preview, or apply category assignments from a JSON file.',
587
+ 'Validate, preview, or apply transaction sharing and category assignments from a JSON file.',
588
588
  '',
589
589
  'Usage:',
590
590
  ' sloth-agent assign --input FILE [--apply] [--base-url URL]',
@@ -607,7 +607,11 @@ export function assignHelpText() {
607
607
  '',
608
608
  'Input:',
609
609
  ' The top-level object must contain an assignments array.',
610
- ' Each assignment requires transactionRef and a categoryId or non-empty categorySplits.',
610
+ ' Each assignment requires transactionRef and at least one category operation or sharing object.',
611
+ ' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
612
+ ' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
613
+ ' Omitted split values use saved defaults for a first share and preserve an existing split.',
614
+ ' Set sharing.isShared to false on its own to unshare and retain the dormant joint category.',
611
615
  ' Copy the exact transactionRef from transactions output and categoryId from',
612
616
  ' categories output. The example values below are placeholders.',
613
617
  ' Set categoryId to null to clear an assignment.',
@@ -619,7 +623,8 @@ export function assignHelpText() {
619
623
  ' a split lineItemId is optional.',
620
624
  ' incomeSubtype is optional and accepts "pay", "interest", or null.',
621
625
  ' assignmentScope is optional and accepts "personal" or "joint".',
622
- ' Personal is used when assignmentScope is omitted.',
626
+ ' The transaction\'s native scope is used when assignmentScope is omitted for category-only requests.',
627
+ ' Combined requests use Joint when you have no exclusive amount and Personal when you do.',
623
628
  '',
624
629
  'Workflow:',
625
630
  ' sloth-agent categories',
@@ -633,7 +638,8 @@ export function assignHelpText() {
633
638
  ' "assignments": [',
634
639
  ' {',
635
640
  ' "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",',
636
- ' "assignmentScope": "personal",',
641
+ ' "sharing": { "isShared": true, "shareRatio": 0.6 },',
642
+ ' "assignmentScope": "joint",',
637
643
  ' "categoryId": "PASTE_A_CATEGORY_ID_HERE",',
638
644
  ' "lineItemId": "PASTE_A_LINE_ITEM_ID_HERE"',
639
645
  ' }',
@@ -1018,6 +1024,8 @@ function buildTransactionsQuery(filters) {
1018
1024
  if (filters.uncategorized !== undefined) {
1019
1025
  params.set('uncategorized', String(filters.uncategorized));
1020
1026
  }
1027
+ if (filters.shared !== undefined)
1028
+ params.set('shared', String(filters.shared));
1021
1029
  if (filters.limit !== undefined)
1022
1030
  params.set('limit', String(filters.limit));
1023
1031
  if (filters.startDate !== undefined)
@@ -1233,6 +1241,17 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1233
1241
  });
1234
1242
  return 0;
1235
1243
  }
1244
+ const assignmentPayload = parsed.command === 'assign'
1245
+ ? validateAssignmentPayload(readAssignmentFile(parsed.input))
1246
+ : undefined;
1247
+ if (parsed.command === 'assign' && !parsed.apply) {
1248
+ writeJson(writeStdout, {
1249
+ dryRun: true,
1250
+ endpoint: `${baseUrl}/api/agent/v1/transaction-assignments`,
1251
+ payload: assignmentPayload,
1252
+ });
1253
+ return 0;
1254
+ }
1236
1255
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
1237
1256
  token = credential.token;
1238
1257
  const headers = requestHeaders(token);
@@ -1417,12 +1436,8 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1417
1436
  return 0;
1418
1437
  }
1419
1438
  if (parsed.command === 'assign') {
1420
- const payload = validateAssignmentPayload(readAssignmentFile(parsed.input));
1439
+ const payload = assignmentPayload;
1421
1440
  const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
1422
- if (!parsed.apply) {
1423
- writeJson(writeStdout, { dryRun: true, endpoint, payload });
1424
- return 0;
1425
- }
1426
1441
  const response = await fetchImplementation(endpoint, {
1427
1442
  method: 'POST',
1428
1443
  headers: {
package/dist/contracts.js CHANGED
@@ -46,6 +46,7 @@ function validateAssignment(value, index) {
46
46
  const assignment = requireObject(value, label);
47
47
  rejectUnknownFields(assignment, new Set([
48
48
  'transactionRef',
49
+ 'sharing',
49
50
  'assignmentScope',
50
51
  'categoryId',
51
52
  'lineItemId',
@@ -53,6 +54,48 @@ function validateAssignment(value, index) {
53
54
  'incomeSubtype',
54
55
  ]), label);
55
56
  requireString(assignment.transactionRef, `${label}.transactionRef`);
57
+ let sharing;
58
+ if (assignment.sharing !== undefined) {
59
+ const sharingValue = requireObject(assignment.sharing, `${label}.sharing`);
60
+ rejectUnknownFields(sharingValue, new Set([
61
+ 'isShared',
62
+ 'shareRatio',
63
+ 'userExclusiveAmountPence',
64
+ 'partnerExclusiveAmountPence',
65
+ ]), `${label}.sharing`);
66
+ if (typeof sharingValue.isShared !== 'boolean') {
67
+ throw new UsageError(`${label}.sharing.isShared must be true or false`);
68
+ }
69
+ if (sharingValue.shareRatio !== undefined
70
+ && (typeof sharingValue.shareRatio !== 'number'
71
+ || !Number.isFinite(sharingValue.shareRatio)
72
+ || sharingValue.shareRatio < 0
73
+ || sharingValue.shareRatio > 1)) {
74
+ throw new UsageError(`${label}.sharing.shareRatio must be a number from 0 to 1`);
75
+ }
76
+ for (const field of ['userExclusiveAmountPence', 'partnerExclusiveAmountPence']) {
77
+ if (sharingValue[field] !== undefined
78
+ && (!Number.isSafeInteger(sharingValue[field]) || Number(sharingValue[field]) < 0)) {
79
+ throw new UsageError(`${label}.sharing.${field} must be a nonnegative safe integer`);
80
+ }
81
+ }
82
+ if (sharingValue.isShared === false
83
+ && (sharingValue.shareRatio !== undefined
84
+ || sharingValue.userExclusiveAmountPence !== undefined
85
+ || sharingValue.partnerExclusiveAmountPence !== undefined)) {
86
+ throw new UsageError(`${label}.sharing cannot include split fields when isShared is false`);
87
+ }
88
+ sharing = {
89
+ isShared: sharingValue.isShared,
90
+ ...(sharingValue.shareRatio === undefined ? {} : { shareRatio: sharingValue.shareRatio }),
91
+ ...(sharingValue.userExclusiveAmountPence === undefined
92
+ ? {}
93
+ : { userExclusiveAmountPence: Number(sharingValue.userExclusiveAmountPence) }),
94
+ ...(sharingValue.partnerExclusiveAmountPence === undefined
95
+ ? {}
96
+ : { partnerExclusiveAmountPence: Number(sharingValue.partnerExclusiveAmountPence) }),
97
+ };
98
+ }
56
99
  if (assignment.assignmentScope !== undefined
57
100
  && assignment.assignmentScope !== 'personal'
58
101
  && assignment.assignmentScope !== 'joint') {
@@ -83,11 +126,20 @@ function validateAssignment(value, index) {
83
126
  const hasCategory = typeof assignment.categoryId === 'string' && assignment.categoryId.trim().length > 0;
84
127
  const isClear = assignment.categoryId === null && (!categorySplits || categorySplits.length === 0);
85
128
  const hasSplits = Array.isArray(categorySplits) && categorySplits.length > 0;
86
- if (!hasCategory && !isClear && !hasSplits) {
129
+ const hasCategoryOperation = hasCategory || isClear || hasSplits;
130
+ if (!hasCategoryOperation && sharing === undefined) {
87
131
  throw new UsageError(`${label}.categoryId or categorySplits is required`);
88
132
  }
133
+ if (!hasCategoryOperation
134
+ && (assignment.assignmentScope !== undefined
135
+ || assignment.lineItemId !== undefined
136
+ || assignment.incomeSubtype !== undefined
137
+ || assignment.categorySplits !== undefined)) {
138
+ throw new UsageError(`${label} category options require categoryId or categorySplits`);
139
+ }
89
140
  return {
90
141
  transactionRef: assignment.transactionRef,
142
+ ...(sharing === undefined ? {} : { sharing }),
91
143
  ...(assignment.assignmentScope !== undefined
92
144
  ? { assignmentScope: assignment.assignmentScope }
93
145
  : {}),
@@ -255,13 +307,64 @@ function isTransactionsResponse(value) {
255
307
  && isRefreshStatus(value.refresh));
256
308
  }
257
309
  function isAssignmentResponse(value) {
310
+ const isResponseSplit = (split) => (isObject(split)
311
+ && hasOnlyFields(split, ['categoryId', 'amountPence', 'lineItemId'])
312
+ && typeof split.categoryId === 'string'
313
+ && isNonnegativeSafeInteger(split.amountPence)
314
+ && split.amountPence > 0
315
+ && (split.lineItemId === undefined || typeof split.lineItemId === 'string'));
316
+ const isContribution = (contribution) => (isObject(contribution)
317
+ && hasOnlyFields(contribution, [
318
+ 'eligible', 'included', 'amountPence', 'categoryId', 'lineItemId',
319
+ 'categorySplits', 'incomeSubtype',
320
+ ])
321
+ && typeof contribution.eligible === 'boolean'
322
+ && typeof contribution.included === 'boolean'
323
+ && isNonnegativeSafeInteger(contribution.amountPence)
324
+ && contribution.amountPence > 0
325
+ && (contribution.categoryId === null || typeof contribution.categoryId === 'string')
326
+ && (contribution.lineItemId === null || typeof contribution.lineItemId === 'string')
327
+ && Array.isArray(contribution.categorySplits)
328
+ && contribution.categorySplits.every(isResponseSplit)
329
+ && (contribution.incomeSubtype === null
330
+ || contribution.incomeSubtype === 'pay'
331
+ || contribution.incomeSubtype === 'interest'));
332
+ const isSharing = (sharing) => (isObject(sharing)
333
+ && hasOnlyFields(sharing, [
334
+ 'isShared', 'shareRatio', 'sharedAmountPence', 'userExclusiveAmountPence',
335
+ 'partnerExclusiveAmountPence', 'jointBudgetContribution',
336
+ ])
337
+ && typeof sharing.isShared === 'boolean'
338
+ && typeof sharing.shareRatio === 'number'
339
+ && Number.isFinite(sharing.shareRatio)
340
+ && sharing.shareRatio >= 0
341
+ && sharing.shareRatio <= 1
342
+ && isNonnegativeSafeInteger(sharing.sharedAmountPence)
343
+ && isNonnegativeSafeInteger(sharing.userExclusiveAmountPence)
344
+ && isNonnegativeSafeInteger(sharing.partnerExclusiveAmountPence)
345
+ && (sharing.jointBudgetContribution === null
346
+ || isContribution(sharing.jointBudgetContribution)));
347
+ const isCategoryResult = (item) => ((item.assignmentScope === 'personal' || item.assignmentScope === 'joint')
348
+ && (item.categoryId === null || typeof item.categoryId === 'string')
349
+ && (item.lineItemId === null || typeof item.lineItemId === 'string')
350
+ && Array.isArray(item.categorySplits)
351
+ && item.categorySplits.every(isResponseSplit)
352
+ && (item.incomeSubtype === null
353
+ || item.incomeSubtype === 'pay'
354
+ || item.incomeSubtype === 'interest'));
258
355
  return (isObject(value)
259
356
  && Array.isArray(value.succeeded)
260
357
  && value.succeeded.every((item) => (isObject(item)
358
+ && hasOnlyFields(item, [
359
+ 'transactionRef', 'categoryId', 'lineItemId', 'categorySplits',
360
+ 'incomeSubtype', 'assignmentScope', 'sharing',
361
+ ])
261
362
  && typeof item.transactionRef === 'string'
262
- && (item.assignmentScope === 'personal' || item.assignmentScope === 'joint')))
363
+ && (isCategoryResult(item) || isSharing(item.sharing))
364
+ && (item.sharing === undefined || isSharing(item.sharing))))
263
365
  && Array.isArray(value.failed)
264
366
  && value.failed.every((item) => (isObject(item)
367
+ && hasOnlyFields(item, ['transactionRef', 'error'])
265
368
  && typeof item.error === 'string'
266
369
  && (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
267
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {