@slothmoney/agent-cli 0.14.1 → 0.15.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-08-21
4
+
5
+ - Add `rules` commands to list, read, preview, apply, and delete notification
6
+ rules anchored to existing transaction references.
7
+ - Add optional one-time PDF renewal-date extraction. Preview mode keeps the
8
+ file local; apply mode sends it for extraction and the service discards it
9
+ without storing the contract.
10
+ - Keep recurring predictions and transaction creation outside the Rules
11
+ contract.
12
+ - Remove the legacy transaction `--account-id` filter and stop exposing
13
+ internal account and connection locator fields in transaction responses.
14
+ Use the opaque `accountRef` returned by `sloth-agent accounts`.
15
+
3
16
  ## 0.14.1 - 2026-08-21
4
17
 
5
18
  - Make every parent help page list its nested commands, and give
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, and categorise transactions through the
3
+ Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, categorise transactions, and configure payment notifications through the
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.14.1 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.15.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -112,6 +112,7 @@ sloth-agent categories create --help
112
112
  sloth-agent line-items --help
113
113
  sloth-agent line-items create --help
114
114
  sloth-agent transactions --help
115
+ sloth-agent rules --help
115
116
  sloth-agent assign --help
116
117
  sloth-agent goals create --help
117
118
  sloth-agent goals update --help
@@ -120,6 +121,56 @@ sloth-agent goals restore --help
120
121
  sloth-agent ask-partner --help
121
122
  ```
122
123
 
124
+ ### Add a transaction notification rule
125
+
126
+ Rules watch future payments that match an existing transaction. They can alert
127
+ you when the amount changes or before a renewal date. They do not create
128
+ transactions or recurring predictions.
129
+
130
+ First, run `sloth-agent transactions` and copy the exact `transactionRef` into
131
+ `rule.json` alongside this rule definition:
132
+
133
+ ```json
134
+ {
135
+ "amountChange": {
136
+ "enabled": true,
137
+ "comparison": "increase",
138
+ "baselinePence": 3184
139
+ },
140
+ "renewalReminder": {
141
+ "enabled": true,
142
+ "renewalDate": "2027-07-30",
143
+ "leadDays": 30
144
+ },
145
+ "delivery": {
146
+ "inApp": true,
147
+ "email": true
148
+ }
149
+ }
150
+ ```
151
+
152
+ Preview the write locally, then apply the same validated file:
153
+
154
+ ```bash
155
+ sloth-agent rules set \
156
+ --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
157
+ --input rule.json
158
+
159
+ sloth-agent rules set \
160
+ --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
161
+ --input rule.json \
162
+ --apply
163
+ ```
164
+
165
+ To extract a renewal date first, scan a PDF no larger than 6 MB:
166
+
167
+ ```bash
168
+ sloth-agent rules scan-contract --contract contract.pdf --apply
169
+ ```
170
+
171
+ The PDF is discarded after extraction and is not stored. Scanning only returns
172
+ the date and confidence; use `rules set` to save the resulting reminder.
173
+
123
174
  ### How transaction categorisation is represented
124
175
 
125
176
  Personal and joint category assignments are separate. A personal assignment
@@ -482,9 +533,8 @@ sloth-agent transactions \
482
533
  --limit 50
483
534
  ```
484
535
 
485
- Copy the value from `sloth-agent accounts`. The older `--account-id` filter is
486
- still accepted for compatibility, but new workflows should use
487
- `--account-ref`. Do not provide both filters in one command.
536
+ Copy the value from `sloth-agent accounts`. Account references are the public
537
+ account identifier for transaction filtering.
488
538
 
489
539
  Account changes are previews unless `--apply` is present. Connected accounts
490
540
  support only goal-savings membership. Manual current accounts support their
package/dist/args.js CHANGED
@@ -177,7 +177,6 @@ function parseTransactions(args) {
177
177
  '--end-date',
178
178
  '--q',
179
179
  '--account-ref',
180
- '--account-id',
181
180
  '--category-id',
182
181
  '--line-item-id',
183
182
  '--assignment-scope',
@@ -213,9 +212,6 @@ function parseTransactions(args) {
213
212
  else if (name === '--account-ref') {
214
213
  filters.accountRef = setOnce(filters.accountRef, parseAccountRef(value), name);
215
214
  }
216
- else if (name === '--account-id') {
217
- filters.accountId = setOnce(filters.accountId, value, name);
218
- }
219
215
  else if (name === '--category-id') {
220
216
  filters.categoryId = setOnce(filters.categoryId, value, name);
221
217
  }
@@ -237,9 +233,6 @@ function parseTransactions(args) {
237
233
  && filters.endDate < filters.startDate) {
238
234
  throw new UsageError('--end-date must not be before --start-date');
239
235
  }
240
- if (filters.accountRef !== undefined && filters.accountId !== undefined) {
241
- throw new UsageError('Use either --account-ref or --account-id, not both');
242
- }
243
236
  return filters;
244
237
  }
245
238
  function parseNamedOptions(args, commandLabel, allowed) {
@@ -752,6 +745,48 @@ function parseGoals(args, baseUrl) {
752
745
  }
753
746
  throw new UsageError(`Unknown goals command: ${subcommand}`);
754
747
  }
748
+ function parseRules(args, baseUrl) {
749
+ const subcommand = args.shift();
750
+ if (subcommand === undefined || subcommand === 'list') {
751
+ if (args.length > 0) {
752
+ throw new UsageError(`Unknown rules list option: ${args[0]}`);
753
+ }
754
+ return withBaseUrl({ command: 'rules-list' }, baseUrl);
755
+ }
756
+ if (subcommand === 'get' || subcommand === 'delete') {
757
+ const allowed = new Set(['--transaction-ref']);
758
+ const { values, apply } = parseNamedOptions(args, `rules ${subcommand}`, allowed);
759
+ const transactionRef = requiredOption(values, '--transaction-ref', `rules ${subcommand}`);
760
+ if (subcommand === 'get') {
761
+ if (apply)
762
+ throw new UsageError('rules get does not accept --apply');
763
+ return withBaseUrl({ command: 'rules-get', transactionRef }, baseUrl);
764
+ }
765
+ return withBaseUrl({
766
+ command: 'rules-delete',
767
+ transactionRef,
768
+ apply,
769
+ }, baseUrl);
770
+ }
771
+ if (subcommand === 'set') {
772
+ const { values, apply } = parseNamedOptions(args, 'rules set', new Set(['--transaction-ref', '--input']));
773
+ return withBaseUrl({
774
+ command: 'rules-set',
775
+ transactionRef: requiredOption(values, '--transaction-ref', 'rules set'),
776
+ input: requiredOption(values, '--input', 'rules set'),
777
+ apply,
778
+ }, baseUrl);
779
+ }
780
+ if (subcommand === 'scan-contract') {
781
+ const { values, apply } = parseNamedOptions(args, 'rules scan-contract', new Set(['--contract']));
782
+ return withBaseUrl({
783
+ command: 'rules-scan-contract',
784
+ contract: requiredOption(values, '--contract', 'rules scan-contract'),
785
+ apply,
786
+ }, baseUrl);
787
+ }
788
+ throw new UsageError(`Unknown rules command: ${subcommand}`);
789
+ }
755
790
  function helpTopic(argv) {
756
791
  const positionals = [];
757
792
  for (let index = 0; index < argv.length; index += 1) {
@@ -791,6 +826,17 @@ function helpTopic(argv) {
791
826
  return 'goals-delete';
792
827
  return 'goals';
793
828
  }
829
+ if (command === 'rules') {
830
+ if (subcommand === 'get')
831
+ return 'rules-get';
832
+ if (subcommand === 'set')
833
+ return 'rules-set';
834
+ if (subcommand === 'delete')
835
+ return 'rules-delete';
836
+ if (subcommand === 'scan-contract')
837
+ return 'rules-scan-contract';
838
+ return 'rules';
839
+ }
794
840
  if (command === 'categories') {
795
841
  if (subcommand === 'create')
796
842
  return 'categories-create';
@@ -845,6 +891,9 @@ export function parseArgs(argv) {
845
891
  if (command === 'goals') {
846
892
  return parseGoals(args, baseUrl);
847
893
  }
894
+ if (command === 'rules') {
895
+ return parseRules(args, baseUrl);
896
+ }
848
897
  if (command === 'categories') {
849
898
  return parseCategories(args, baseUrl);
850
899
  }
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, validateBudgetMovementResponse, validateBudgetUpdatePayload, } from './contracts.js';
4
+ import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, } 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.14.1';
7
+ export const CLI_VERSION = '0.15.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -42,9 +42,14 @@ export function usageText() {
42
42
  ' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply]',
43
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
- ' [--account-ref REF] [--account-id ID] [--category-id ID] [--line-item-id ID]',
45
+ ' [--account-ref REF] [--category-id ID] [--line-item-id ID]',
46
46
  ' [--cursor CURSOR] [--base-url URL]',
47
47
  ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
48
+ ' sloth-agent rules [list] [--base-url URL]',
49
+ ' sloth-agent rules get --transaction-ref REF [--base-url URL]',
50
+ ' sloth-agent rules set --transaction-ref REF --input rule.json [--apply]',
51
+ ' sloth-agent rules delete --transaction-ref REF [--apply]',
52
+ ' sloth-agent rules scan-contract --contract FILE.pdf [--apply]',
48
53
  ' sloth-agent goals [list] [--base-url URL]',
49
54
  ' sloth-agent goals create --name NAME --target-amount AMOUNT',
50
55
  ' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
@@ -589,7 +594,6 @@ export function transactionsHelpText() {
589
594
  ' --q TEXT Optional. Search transactions by text.',
590
595
  ' --account-ref REF Optional. Filter by the opaque accountRef from sloth-agent accounts.',
591
596
  ' Copy the exact sloth_account_v1_... value.',
592
- ' --account-id ID Optional legacy filter by provider or stored account ID.',
593
597
  ' --category-id ID Optional. Filter by category ID.',
594
598
  ' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
595
599
  ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
@@ -601,7 +605,6 @@ export function transactionsHelpText() {
601
605
  '',
602
606
  'Constraints:',
603
607
  ' All filters are omitted by default.',
604
- ' Use either --account-ref or --account-id, not both.',
605
608
  ' --end-date must not be before --start-date.',
606
609
  ' The first transaction read each UTC day may refresh linked bank data.',
607
610
  ' Refresh remotely persists booked transactions and account balances.',
@@ -936,6 +939,135 @@ export function askPartnerHelpText() {
936
939
  ' JSON containing requestId, publicUrl, message, expiresAt, and status.',
937
940
  ].join('\n');
938
941
  }
942
+ export function rulesHelpText() {
943
+ return [
944
+ 'Sloth Agent CLI — rules',
945
+ '',
946
+ 'Manage notifications for future payments that match an existing transaction.',
947
+ 'Rules do not create transactions or recurring predictions.',
948
+ '',
949
+ 'Commands:',
950
+ ' rules list List every saved notification rule.',
951
+ ' rules get Read the rule for one transaction.',
952
+ ' rules set Preview or save a rule for one transaction.',
953
+ ' rules delete Preview or remove a rule.',
954
+ ' rules scan-contract Validate or scan a PDF for its renewal date.',
955
+ '',
956
+ 'Use the exact transactionRef returned by sloth-agent transactions.',
957
+ ...API_ORIGIN_HELP_LINES,
958
+ ].join('\n');
959
+ }
960
+ export function rulesGetHelpText() {
961
+ return [
962
+ 'Sloth Agent CLI — rules get',
963
+ '',
964
+ 'Read the saved notification rule for one existing transaction.',
965
+ '',
966
+ 'Usage:',
967
+ ' sloth-agent rules get --transaction-ref REF',
968
+ '',
969
+ 'Required:',
970
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
971
+ '',
972
+ 'Behavior:',
973
+ ' This command is read-only and requires an agent:read token.',
974
+ '',
975
+ 'Output:',
976
+ ' JSON containing the saved rule, or rule: null when no rule exists.',
977
+ '',
978
+ 'Example:',
979
+ ' sloth-agent rules get --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE',
980
+ ...API_ORIGIN_HELP_LINES,
981
+ ].join('\n');
982
+ }
983
+ export function rulesSetHelpText() {
984
+ return [
985
+ 'Sloth Agent CLI — rules set',
986
+ '',
987
+ 'Preview or save notification conditions for one existing transaction.',
988
+ '',
989
+ 'Usage:',
990
+ ' sloth-agent rules set --transaction-ref REF --input FILE [--apply]',
991
+ '',
992
+ 'Required:',
993
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
994
+ ' --input FILE A JSON rule file such as rule.json.',
995
+ '',
996
+ 'Input:',
997
+ ' {',
998
+ ' "amountChange": {',
999
+ ' "enabled": true,',
1000
+ ' "comparison": "increase",',
1001
+ ' "baselinePence": 3184',
1002
+ ' },',
1003
+ ' "renewalReminder": {',
1004
+ ' "enabled": true,',
1005
+ ' "renewalDate": "2027-07-30",',
1006
+ ' "leadDays": 30',
1007
+ ' },',
1008
+ ' "delivery": { "inApp": true, "email": true }',
1009
+ ' }',
1010
+ '',
1011
+ ' comparison accepts increase or any. baselinePence is a positive integer.',
1012
+ ' renewalDate is YYYY-MM-DD or null when its reminder is disabled.',
1013
+ ' leadDays is an integer from 0 to 365. At least one condition must be enabled.',
1014
+ ' delivery.inApp must be true; delivery.email adds email delivery.',
1015
+ '',
1016
+ 'Write behavior:',
1017
+ ' Without --apply, Sloth validates the file and prints a local preview.',
1018
+ ' With --apply, Sloth replaces the saved rule using a write-enabled token.',
1019
+ '',
1020
+ 'Example:',
1021
+ ' sloth-agent rules set --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \\',
1022
+ ' --input rule.json --apply',
1023
+ ...API_ORIGIN_HELP_LINES,
1024
+ ].join('\n');
1025
+ }
1026
+ export function rulesDeleteHelpText() {
1027
+ return [
1028
+ 'Sloth Agent CLI — rules delete',
1029
+ '',
1030
+ 'Preview or remove the notification rule for one existing transaction.',
1031
+ '',
1032
+ 'Usage:',
1033
+ ' sloth-agent rules delete --transaction-ref REF [--apply]',
1034
+ '',
1035
+ 'Required:',
1036
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
1037
+ '',
1038
+ 'Write behavior:',
1039
+ ' Without --apply, Sloth prints a local deletion preview.',
1040
+ ' With --apply, Sloth removes the rule using a write-enabled token.',
1041
+ ...API_ORIGIN_HELP_LINES,
1042
+ ].join('\n');
1043
+ }
1044
+ export function rulesScanContractHelpText() {
1045
+ return [
1046
+ 'Sloth Agent CLI — rules scan-contract',
1047
+ '',
1048
+ 'Validate or scan a contract PDF for its renewal date.',
1049
+ '',
1050
+ 'Usage:',
1051
+ ' sloth-agent rules scan-contract --contract FILE.pdf [--apply]',
1052
+ '',
1053
+ 'Required:',
1054
+ ' --contract FILE.pdf A PDF no larger than 6 MB.',
1055
+ '',
1056
+ 'Behavior:',
1057
+ ' Without --apply, the PDF is validated locally and is not sent anywhere.',
1058
+ ' With --apply, the PDF is sent using a write-enabled token.',
1059
+ '',
1060
+ 'Contract privacy:',
1061
+ ' Sloth extracts a renewal date and then discards the file. It is not stored.',
1062
+ '',
1063
+ 'Output:',
1064
+ ' JSON containing renewalDate and confidence: high, medium, or low.',
1065
+ '',
1066
+ 'Example:',
1067
+ ' sloth-agent rules scan-contract --contract contract.pdf --apply',
1068
+ ...API_ORIGIN_HELP_LINES,
1069
+ ].join('\n');
1070
+ }
939
1071
  export function commandHelpText(topic) {
940
1072
  const helpByTopic = {
941
1073
  auth: authHelpText,
@@ -958,6 +1090,11 @@ export function commandHelpText(topic) {
958
1090
  'line-items-rename': lineItemsRenameHelpText,
959
1091
  transactions: transactionsHelpText,
960
1092
  assign: assignHelpText,
1093
+ rules: rulesHelpText,
1094
+ 'rules-get': rulesGetHelpText,
1095
+ 'rules-set': rulesSetHelpText,
1096
+ 'rules-delete': rulesDeleteHelpText,
1097
+ 'rules-scan-contract': rulesScanContractHelpText,
961
1098
  goals: goalsHelpText,
962
1099
  'goals-list': goalsListHelpText,
963
1100
  'goals-create': goalsCreateHelpText,
@@ -1064,6 +1201,29 @@ function readBudgetFile(filePath) {
1064
1201
  throw new UsageError(`Failed to read budget JSON: ${message}`);
1065
1202
  }
1066
1203
  }
1204
+ function readNotificationRuleFile(filePath) {
1205
+ try {
1206
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1207
+ }
1208
+ catch (error) {
1209
+ const message = error instanceof Error ? error.message : String(error);
1210
+ throw new UsageError(`Failed to read notification rule JSON: ${message}`);
1211
+ }
1212
+ }
1213
+ function readContractPdf(filePath) {
1214
+ let file;
1215
+ try {
1216
+ file = fs.readFileSync(filePath);
1217
+ }
1218
+ catch (error) {
1219
+ const message = error instanceof Error ? error.message : String(error);
1220
+ throw new UsageError(`Failed to read contract PDF: ${message}`);
1221
+ }
1222
+ if (file.length === 0 || file.length > 6 * 1024 * 1024 || file.subarray(0, 4).toString() !== '%PDF') {
1223
+ throw new UsageError('Contract must be a PDF no larger than 6 MB');
1224
+ }
1225
+ return file;
1226
+ }
1067
1227
  function buildTransactionsQuery(filters) {
1068
1228
  const params = new URLSearchParams();
1069
1229
  if (filters.uncategorized !== undefined) {
@@ -1081,8 +1241,6 @@ function buildTransactionsQuery(filters) {
1081
1241
  params.set('q', filters.q);
1082
1242
  if (filters.accountRef !== undefined)
1083
1243
  params.set('accountRef', filters.accountRef);
1084
- if (filters.accountId !== undefined)
1085
- params.set('accountId', filters.accountId);
1086
1244
  if (filters.categoryId !== undefined)
1087
1245
  params.set('categoryId', filters.categoryId);
1088
1246
  if (filters.lineItemId !== undefined)
@@ -1299,6 +1457,43 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1299
1457
  });
1300
1458
  return 0;
1301
1459
  }
1460
+ const notificationRulePayload = parsed.command === 'rules-set'
1461
+ ? validateNotificationRulePayload(readNotificationRuleFile(parsed.input))
1462
+ : undefined;
1463
+ const notificationRuleEndpoint = (parsed.command === 'rules-get'
1464
+ || parsed.command === 'rules-set'
1465
+ || parsed.command === 'rules-delete')
1466
+ ? `${baseUrl}/api/agent/v1/notification-rules/for-transaction?${new URLSearchParams({
1467
+ transactionRef: parsed.transactionRef,
1468
+ }).toString()}`
1469
+ : undefined;
1470
+ if (parsed.command === 'rules-set' && !parsed.apply) {
1471
+ writeJson(writeStdout, {
1472
+ dryRun: true,
1473
+ endpoint: notificationRuleEndpoint,
1474
+ method: 'PUT',
1475
+ payload: { transactionRef: parsed.transactionRef, ...notificationRulePayload },
1476
+ });
1477
+ return 0;
1478
+ }
1479
+ if (parsed.command === 'rules-delete' && !parsed.apply) {
1480
+ writeJson(writeStdout, {
1481
+ dryRun: true,
1482
+ endpoint: notificationRuleEndpoint,
1483
+ method: 'DELETE',
1484
+ });
1485
+ return 0;
1486
+ }
1487
+ if (parsed.command === 'rules-scan-contract' && !parsed.apply) {
1488
+ const contract = readContractPdf(parsed.contract);
1489
+ writeJson(writeStdout, {
1490
+ dryRun: true,
1491
+ endpoint: `${baseUrl}/api/agent/v1/notification-rules/extract-renewal`,
1492
+ method: 'POST',
1493
+ contract: { bytes: contract.length, mimeType: 'application/pdf' },
1494
+ });
1495
+ return 0;
1496
+ }
1302
1497
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
1303
1498
  token = credential.token;
1304
1499
  const headers = requestHeaders(token);
@@ -1498,6 +1693,51 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1498
1693
  writeJson(writeStdout, data);
1499
1694
  return hasFailures(data) ? 1 : 0;
1500
1695
  }
1696
+ if (parsed.command === 'rules-list') {
1697
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/notification-rules`, {
1698
+ method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1699
+ });
1700
+ writeJson(writeStdout, parseApiResponse('rules-list', await parseHttpResponse(response, token)));
1701
+ return 0;
1702
+ }
1703
+ if (parsed.command === 'rules-get') {
1704
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1705
+ method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1706
+ });
1707
+ writeJson(writeStdout, parseApiResponse('rules-get', await parseHttpResponse(response, token)));
1708
+ return 0;
1709
+ }
1710
+ if (parsed.command === 'rules-set') {
1711
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1712
+ method: 'PUT',
1713
+ headers: { ...headers, 'Content-Type': 'application/json' },
1714
+ body: JSON.stringify({ transactionRef: parsed.transactionRef, ...notificationRulePayload }),
1715
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1716
+ });
1717
+ writeJson(writeStdout, parseApiResponse('rules-set', await parseHttpResponse(response, token)));
1718
+ return 0;
1719
+ }
1720
+ if (parsed.command === 'rules-delete') {
1721
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1722
+ method: 'DELETE', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1723
+ });
1724
+ writeJson(writeStdout, parseApiResponse('rules-delete', await parseHttpResponse(response, token)));
1725
+ return 0;
1726
+ }
1727
+ if (parsed.command === 'rules-scan-contract') {
1728
+ const contract = readContractPdf(parsed.contract);
1729
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/notification-rules/extract-renewal`, {
1730
+ method: 'POST',
1731
+ headers: { ...headers, 'Content-Type': 'application/json' },
1732
+ body: JSON.stringify({
1733
+ fileBase64: contract.toString('base64'),
1734
+ mimeType: 'application/pdf',
1735
+ }),
1736
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1737
+ });
1738
+ writeJson(writeStdout, parseApiResponse('rules-scan-contract', await parseHttpResponse(response, token)));
1739
+ return 0;
1740
+ }
1501
1741
  if (parsed.command === 'ask-partner') {
1502
1742
  const response = await fetchImplementation(`${baseUrl}/api/agent/v1/transaction-explanation-requests`, {
1503
1743
  method: 'POST',
package/dist/contracts.js CHANGED
@@ -27,6 +27,102 @@ function rejectUnknownFields(value, allowed, label) {
27
27
  if (unknown)
28
28
  throw new UsageError(`${label} contains unknown field: ${unknown}`);
29
29
  }
30
+ export function validateNotificationRulePayload(value) {
31
+ const payload = requireObject(value, 'notification rule payload');
32
+ rejectUnknownFields(payload, new Set(['amountChange', 'renewalReminder', 'delivery']), 'notification rule payload');
33
+ const amountChange = requireObject(payload.amountChange, 'amountChange');
34
+ const renewalReminder = requireObject(payload.renewalReminder, 'renewalReminder');
35
+ const delivery = requireObject(payload.delivery, 'delivery');
36
+ rejectUnknownFields(amountChange, new Set(['enabled', 'comparison', 'baselinePence']), 'amountChange');
37
+ rejectUnknownFields(renewalReminder, new Set(['enabled', 'renewalDate', 'leadDays']), 'renewalReminder');
38
+ rejectUnknownFields(delivery, new Set(['inApp', 'email']), 'delivery');
39
+ if (typeof amountChange.enabled !== 'boolean')
40
+ throw new UsageError('amountChange.enabled must be true or false');
41
+ if (amountChange.comparison !== 'increase' && amountChange.comparison !== 'any') {
42
+ throw new UsageError('amountChange.comparison must be increase or any');
43
+ }
44
+ if (!Number.isSafeInteger(amountChange.baselinePence) || Number(amountChange.baselinePence) <= 0) {
45
+ throw new UsageError('amountChange.baselinePence must be a positive integer');
46
+ }
47
+ if (typeof renewalReminder.enabled !== 'boolean')
48
+ throw new UsageError('renewalReminder.enabled must be true or false');
49
+ if (renewalReminder.renewalDate !== null && !isIsoDate(renewalReminder.renewalDate)) {
50
+ throw new UsageError('renewalReminder.renewalDate must be YYYY-MM-DD or null');
51
+ }
52
+ if (renewalReminder.enabled && renewalReminder.renewalDate === null) {
53
+ throw new UsageError('renewalReminder.renewalDate is required when enabled');
54
+ }
55
+ if (!Number.isSafeInteger(renewalReminder.leadDays) || Number(renewalReminder.leadDays) < 0 || Number(renewalReminder.leadDays) > 365) {
56
+ throw new UsageError('renewalReminder.leadDays must be an integer from 0 to 365');
57
+ }
58
+ if (delivery.inApp !== true || typeof delivery.email !== 'boolean') {
59
+ throw new UsageError('delivery must include inApp: true and an email boolean');
60
+ }
61
+ if (!amountChange.enabled && !renewalReminder.enabled) {
62
+ throw new UsageError('At least one notification rule must be enabled');
63
+ }
64
+ return {
65
+ amountChange: {
66
+ enabled: amountChange.enabled,
67
+ comparison: amountChange.comparison,
68
+ baselinePence: Number(amountChange.baselinePence),
69
+ },
70
+ renewalReminder: {
71
+ enabled: renewalReminder.enabled,
72
+ renewalDate: renewalReminder.renewalDate,
73
+ leadDays: Number(renewalReminder.leadDays),
74
+ },
75
+ delivery: { inApp: true, email: delivery.email },
76
+ };
77
+ }
78
+ function isNotificationRule(value) {
79
+ if (!isObject(value))
80
+ return false;
81
+ try {
82
+ validateNotificationRulePayload({
83
+ amountChange: value.amountChange,
84
+ renewalReminder: value.renewalReminder,
85
+ delivery: value.delivery,
86
+ });
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ return hasOnlyFields(value, [
92
+ 'id', 'transactionRef', 'merchantName', 'currency', 'sourceAmountPence', 'amountChange',
93
+ 'renewalReminder', 'delivery', 'createdAt', 'updatedAt',
94
+ ])
95
+ && typeof value.id === 'string'
96
+ && typeof value.transactionRef === 'string'
97
+ && typeof value.merchantName === 'string'
98
+ && typeof value.currency === 'string'
99
+ && Number.isSafeInteger(value.sourceAmountPence)
100
+ && typeof value.createdAt === 'string'
101
+ && typeof value.updatedAt === 'string';
102
+ }
103
+ function isNotificationRuleResponse(value) {
104
+ return isObject(value)
105
+ && hasOnlyFields(value, ['rule'])
106
+ && (value.rule === null || isNotificationRule(value.rule));
107
+ }
108
+ function isNotificationRuleListResponse(value) {
109
+ return isObject(value)
110
+ && hasOnlyFields(value, ['rules'])
111
+ && Array.isArray(value.rules)
112
+ && value.rules.every(isNotificationRule);
113
+ }
114
+ function isNotificationRuleDeleteResponse(value) {
115
+ return isObject(value)
116
+ && hasOnlyFields(value, ['deleted', 'transactionRef'])
117
+ && value.deleted === true
118
+ && typeof value.transactionRef === 'string';
119
+ }
120
+ function isRenewalExtractionResponse(value) {
121
+ return isObject(value)
122
+ && hasOnlyFields(value, ['renewalDate', 'confidence'])
123
+ && isIsoDate(value.renewalDate)
124
+ && (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'low');
125
+ }
30
126
  function validateSplit(value, index, splitIndex) {
31
127
  const label = `assignments[${index}].categorySplits[${splitIndex}]`;
32
128
  const split = requireObject(value, label);
@@ -254,9 +350,6 @@ function isTransaction(value) {
254
350
  && typeof value.date === 'string'
255
351
  && value.status === 'booked'
256
352
  && isAccountRef(value.accountRef)
257
- && typeof value.accountId === 'string'
258
- && typeof value.accountDocId === 'string'
259
- && typeof value.requisitionId === 'string'
260
353
  && (value.scope === 'personal' || value.scope === 'joint')
261
354
  && (value.categoryId === null || typeof value.categoryId === 'string')
262
355
  && (value.lineItemId === null || typeof value.lineItemId === 'string')
@@ -749,15 +842,23 @@ export function parseApiResponse(command, value) {
749
842
  ? isLineItemMutationResponse(value)
750
843
  : command === 'transactions'
751
844
  ? isTransactionsResponse(value)
752
- : command === 'assign'
753
- ? isAssignmentResponse(value)
754
- : command === 'ask-partner'
755
- ? isPartnerResponse(value)
756
- : command === 'goals-list'
757
- ? isGoalsResponse(value)
758
- : command === 'goals-delete'
759
- ? isGoalDeleteResponse(value)
760
- : isGoalMutationResponse(value);
845
+ : command === 'rules-list'
846
+ ? isNotificationRuleListResponse(value)
847
+ : command === 'rules-get' || command === 'rules-set'
848
+ ? isNotificationRuleResponse(value)
849
+ : command === 'rules-delete'
850
+ ? isNotificationRuleDeleteResponse(value)
851
+ : command === 'rules-scan-contract'
852
+ ? isRenewalExtractionResponse(value)
853
+ : command === 'assign'
854
+ ? isAssignmentResponse(value)
855
+ : command === 'ask-partner'
856
+ ? isPartnerResponse(value)
857
+ : command === 'goals-list'
858
+ ? isGoalsResponse(value)
859
+ : command === 'goals-delete'
860
+ ? isGoalDeleteResponse(value)
861
+ : isGoalMutationResponse(value);
761
862
  if (!valid) {
762
863
  const label = command === 'assign' ? 'assignment' : command;
763
864
  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.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {