@slothmoney/agent-cli 0.16.0 → 0.17.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 +10 -0
- package/README.md +20 -4
- package/dist/cli.js +111 -16
- package/dist/contracts.js +76 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 - 2026-08-25
|
|
4
|
+
|
|
5
|
+
- Run applied transaction-assignment batches through resumable server
|
|
6
|
+
operations while keeping the existing `assign --input ... --apply` command
|
|
7
|
+
and terminal `succeeded`/`failed` JSON output unchanged.
|
|
8
|
+
- Retry transient submission and status requests with a deterministic
|
|
9
|
+
request key, so re-running an interrupted command resumes the same operation.
|
|
10
|
+
- Strictly validate operation progress, expiry, counts, and ordered terminal
|
|
11
|
+
item receipts before printing an assignment result.
|
|
12
|
+
|
|
3
13
|
## 0.16.0 - 2026-08-21
|
|
4
14
|
|
|
5
15
|
- Add receipt image extraction plus read, preview-by-default attach, and remove
|
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.
|
|
18
|
+
npm exec --yes --package=@slothmoney/agent-cli@0.17.0 -- sloth-agent --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Authenticate
|
|
@@ -302,6 +302,10 @@ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
|
|
|
302
302
|
|
|
303
303
|
These are placeholders. Do not submit the example values.
|
|
304
304
|
|
|
305
|
+
Each `transactionRef` may appear only once in an assignment file. Split one
|
|
306
|
+
transaction across categories with `categorySplits` instead of adding the same
|
|
307
|
+
transaction twice.
|
|
308
|
+
|
|
305
309
|
4. Preview the assignment without writing:
|
|
306
310
|
|
|
307
311
|
```bash
|
|
@@ -321,7 +325,17 @@ sloth-agent assign --input assignments.json --apply
|
|
|
321
325
|
|
|
322
326
|
This step requires a token created with **Allow changes**.
|
|
323
327
|
|
|
324
|
-
|
|
328
|
+
The CLI submits a durable server operation and polls its authenticated status
|
|
329
|
+
until every item has finished. It then prints the same `succeeded` and `failed`
|
|
330
|
+
arrays as before, so existing agent workflows do not need to change. Inspect
|
|
331
|
+
every item in both arrays.
|
|
332
|
+
|
|
333
|
+
If the command is interrupted or a request times out, re-run the same command
|
|
334
|
+
with the same assignment input. The CLI derives the same request key
|
|
335
|
+
from the validated assignments, so the server resumes the existing operation
|
|
336
|
+
instead of applying the batch again. The server retains operation status and
|
|
337
|
+
item receipts for seven days. Changing the assignments creates a different
|
|
338
|
+
operation.
|
|
325
339
|
|
|
326
340
|
6. Check the result in the same assignment scope that you changed. Successful
|
|
327
341
|
assignments update the category and optional budget line item on the
|
|
@@ -765,8 +779,10 @@ Command results are JSON on stdout. Diagnostics are written to stderr.
|
|
|
765
779
|
| `2` | Invalid command, option, URL, date, auth input, goal input, or assignment input |
|
|
766
780
|
| `3` | No credential or native secure storage is unavailable |
|
|
767
781
|
|
|
768
|
-
Assignment writes
|
|
769
|
-
returns exit code `1`
|
|
782
|
+
Assignment writes run as durable, best-effort operations. The CLI waits for the
|
|
783
|
+
terminal result and returns exit code `1` when any item failed, while preserving
|
|
784
|
+
the complete `succeeded` and `failed` arrays on stdout. Re-running an interrupted
|
|
785
|
+
command with the same input resumes the same server operation.
|
|
770
786
|
|
|
771
787
|
## Development
|
|
772
788
|
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import nodePath from 'node:path';
|
|
3
4
|
import { parseArgs, resolveBaseUrl, } from './args.js';
|
|
4
5
|
import { ICON_KEYS } from './category-metadata.js';
|
|
5
|
-
import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
|
|
6
|
+
import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
|
|
6
7
|
import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
|
|
7
8
|
import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
|
|
8
|
-
export const CLI_VERSION = '0.
|
|
9
|
+
export const CLI_VERSION = '0.17.0';
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
10
11
|
const MAX_CONTRACT_PDF_BYTES = 6_000_000;
|
|
11
12
|
const API_ORIGIN_HELP_LINES = [
|
|
@@ -16,6 +17,8 @@ const API_ORIGIN_HELP_LINES = [
|
|
|
16
17
|
' Use an origin-only URL with no credentials, path, query, or fragment.',
|
|
17
18
|
' HTTPS is required except for localhost development.',
|
|
18
19
|
];
|
|
20
|
+
const ASSIGNMENT_REQUEST_ATTEMPTS = 3;
|
|
21
|
+
const ASSIGNMENT_RETRY_DELAY_MS = 500;
|
|
19
22
|
export function usageText() {
|
|
20
23
|
return [
|
|
21
24
|
'Sloth Agent CLI',
|
|
@@ -654,13 +657,19 @@ export function assignHelpText() {
|
|
|
654
657
|
' the payload it would send. It does not contact Sloth Money, verify the',
|
|
655
658
|
' transactionRef or category values, or write anything.',
|
|
656
659
|
' A successful preview does not guarantee that applying it will succeed.',
|
|
657
|
-
' With --apply,
|
|
658
|
-
'
|
|
660
|
+
' With --apply, the CLI submits one durable server operation, then polls authenticated status',
|
|
661
|
+
' until every item finishes. Transient submission and status failures are retried.',
|
|
662
|
+
' Re-run the same command with the same assignment input after an interruption;',
|
|
663
|
+
' the CLI resumes the same operation instead of duplicating its work.',
|
|
664
|
+
' Operation status and item receipts remain available on the server for seven days.',
|
|
665
|
+
' Assignments are best-effort; any failed item makes the command exit with code 1',
|
|
666
|
+
' while the complete terminal result remains available on stdout.',
|
|
659
667
|
' Applying requires a write-enabled token created with Allow changes.',
|
|
660
668
|
'',
|
|
661
669
|
'Input:',
|
|
662
670
|
' The top-level object must contain an assignments array.',
|
|
663
671
|
' Each assignment requires transactionRef and at least one category operation or sharing object.',
|
|
672
|
+
' Each transactionRef may appear only once in the assignments array.',
|
|
664
673
|
' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
|
|
665
674
|
' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
|
|
666
675
|
' Omitted split values use saved defaults for a first share and preserve an existing split.',
|
|
@@ -702,7 +711,7 @@ export function assignHelpText() {
|
|
|
702
711
|
'',
|
|
703
712
|
'Output:',
|
|
704
713
|
' Preview mode returns dryRun, endpoint, and the validated payload.',
|
|
705
|
-
' Apply mode returns succeeded and failed
|
|
714
|
+
' Apply mode waits for the durable operation and returns succeeded and failed arrays.',
|
|
706
715
|
' Successful assignments update the original transaction. See the result in',
|
|
707
716
|
' Sloth Money → Transactions or read the transaction again through the CLI.',
|
|
708
717
|
' Assignments do not create a separate list.',
|
|
@@ -1407,6 +1416,99 @@ function requestHeaders(token) {
|
|
|
1407
1416
|
'User-Agent': `sloth-agent/${CLI_VERSION}`,
|
|
1408
1417
|
};
|
|
1409
1418
|
}
|
|
1419
|
+
function assignmentIdempotencyKey(payload) {
|
|
1420
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
1421
|
+
}
|
|
1422
|
+
function isRetryableAssignmentRequestError(error) {
|
|
1423
|
+
if (error instanceof ApiError) {
|
|
1424
|
+
return error.status !== undefined
|
|
1425
|
+
&& [408, 425, 429, 499, 500, 502, 503, 504].includes(error.status);
|
|
1426
|
+
}
|
|
1427
|
+
return error instanceof TypeError
|
|
1428
|
+
|| (error instanceof Error && error.name === 'AbortError');
|
|
1429
|
+
}
|
|
1430
|
+
async function withAssignmentRequestRecovery(request, sleep) {
|
|
1431
|
+
let lastError;
|
|
1432
|
+
for (let attempt = 1; attempt <= ASSIGNMENT_REQUEST_ATTEMPTS; attempt += 1) {
|
|
1433
|
+
try {
|
|
1434
|
+
return await request();
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
lastError = error;
|
|
1438
|
+
if (!isRetryableAssignmentRequestError(error) || attempt === ASSIGNMENT_REQUEST_ATTEMPTS) {
|
|
1439
|
+
throw error;
|
|
1440
|
+
}
|
|
1441
|
+
await sleep(ASSIGNMENT_RETRY_DELAY_MS * attempt);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
throw lastError;
|
|
1445
|
+
}
|
|
1446
|
+
async function parseAssignmentOperationHttpResponse(response, token, expectedStatus) {
|
|
1447
|
+
const data = await parseHttpResponse(response, token);
|
|
1448
|
+
if (response.status !== expectedStatus) {
|
|
1449
|
+
throw new ApiError(`Agent API returned status ${response.status}; expected ${expectedStatus}`, response.status);
|
|
1450
|
+
}
|
|
1451
|
+
return parseAssignmentOperationResponse(data);
|
|
1452
|
+
}
|
|
1453
|
+
function assertAssignmentOperationMatchesPayload(operation, payload, expectedOperationId) {
|
|
1454
|
+
if (operation.itemCount !== payload.assignments.length
|
|
1455
|
+
|| (expectedOperationId !== undefined && operation.operationId !== expectedOperationId)
|
|
1456
|
+
|| (operation.status === 'completed'
|
|
1457
|
+
&& operation.results?.some((result, index) => (result.transactionRef !== payload.assignments[index]?.transactionRef)))) {
|
|
1458
|
+
throw new ApiError('Assignment operation response did not match the submitted assignment order');
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async function applyAssignments(fetchImplementation, sleep, baseUrl, token, payload) {
|
|
1462
|
+
const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
|
|
1463
|
+
const idempotencyKey = assignmentIdempotencyKey(payload);
|
|
1464
|
+
let operation;
|
|
1465
|
+
try {
|
|
1466
|
+
operation = await withAssignmentRequestRecovery(async () => {
|
|
1467
|
+
const response = await fetchImplementation(endpoint, {
|
|
1468
|
+
method: 'POST',
|
|
1469
|
+
headers: {
|
|
1470
|
+
...requestHeaders(token),
|
|
1471
|
+
'Content-Type': 'application/json',
|
|
1472
|
+
'Idempotency-Key': idempotencyKey,
|
|
1473
|
+
},
|
|
1474
|
+
body: JSON.stringify(payload),
|
|
1475
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1476
|
+
});
|
|
1477
|
+
return parseAssignmentOperationHttpResponse(response, token, 202);
|
|
1478
|
+
}, sleep);
|
|
1479
|
+
}
|
|
1480
|
+
catch (error) {
|
|
1481
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1482
|
+
throw error;
|
|
1483
|
+
throw new ApiError('Assignment submission could not be confirmed. '
|
|
1484
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1485
|
+
}
|
|
1486
|
+
assertAssignmentOperationMatchesPayload(operation, payload);
|
|
1487
|
+
const operationId = operation.operationId;
|
|
1488
|
+
while (operation.status !== 'completed') {
|
|
1489
|
+
await sleep(operation.pollAfterMs);
|
|
1490
|
+
const statusEndpoint = `${endpoint}/${encodeURIComponent(operationId)}`;
|
|
1491
|
+
try {
|
|
1492
|
+
const nextOperation = await withAssignmentRequestRecovery(async () => {
|
|
1493
|
+
const response = await fetchImplementation(statusEndpoint, {
|
|
1494
|
+
method: 'GET',
|
|
1495
|
+
headers: requestHeaders(token),
|
|
1496
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1497
|
+
});
|
|
1498
|
+
return parseAssignmentOperationHttpResponse(response, token, 200);
|
|
1499
|
+
}, sleep);
|
|
1500
|
+
assertAssignmentOperationMatchesPayload(nextOperation, payload, operationId);
|
|
1501
|
+
operation = nextOperation;
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1505
|
+
throw error;
|
|
1506
|
+
throw new ApiError('Assignment status could not be recovered. '
|
|
1507
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return toLegacyAssignmentResponse(operation);
|
|
1511
|
+
}
|
|
1410
1512
|
async function validateCredentialRemotely(fetchImplementation, origin, token) {
|
|
1411
1513
|
const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
|
|
1412
1514
|
method: 'GET',
|
|
@@ -1442,6 +1544,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1442
1544
|
?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
1443
1545
|
const readSecret = options.readSecret ?? defaultReadSecret;
|
|
1444
1546
|
const readStdin = options.readStdin ?? defaultReadStdin;
|
|
1547
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
|
|
1548
|
+
setTimeout(resolve, milliseconds);
|
|
1549
|
+
}));
|
|
1445
1550
|
const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
|
|
1446
1551
|
const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
|
|
1447
1552
|
let token;
|
|
@@ -1884,17 +1989,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1884
1989
|
}
|
|
1885
1990
|
if (parsed.command === 'assign') {
|
|
1886
1991
|
const payload = assignmentPayload;
|
|
1887
|
-
const
|
|
1888
|
-
const response = await fetchImplementation(endpoint, {
|
|
1889
|
-
method: 'POST',
|
|
1890
|
-
headers: {
|
|
1891
|
-
...headers,
|
|
1892
|
-
'Content-Type': 'application/json',
|
|
1893
|
-
},
|
|
1894
|
-
body: JSON.stringify(payload),
|
|
1895
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1896
|
-
});
|
|
1897
|
-
const data = parseApiResponse('assign', await parseHttpResponse(response, token));
|
|
1992
|
+
const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
|
|
1898
1993
|
writeJson(writeStdout, data);
|
|
1899
1994
|
return hasFailures(data) ? 1 : 0;
|
|
1900
1995
|
}
|
package/dist/contracts.js
CHANGED
|
@@ -269,7 +269,15 @@ export function validateAssignmentPayload(value) {
|
|
|
269
269
|
if (payload.assignments.length < 1 || payload.assignments.length > 100) {
|
|
270
270
|
throw new UsageError('assignments must contain between 1 and 100 items');
|
|
271
271
|
}
|
|
272
|
-
|
|
272
|
+
const assignments = payload.assignments.map(validateAssignment);
|
|
273
|
+
const transactionRefs = new Set();
|
|
274
|
+
for (const assignment of assignments) {
|
|
275
|
+
if (transactionRefs.has(assignment.transactionRef)) {
|
|
276
|
+
throw new UsageError('Each assignments[].transactionRef must be unique');
|
|
277
|
+
}
|
|
278
|
+
transactionRefs.add(assignment.transactionRef);
|
|
279
|
+
}
|
|
280
|
+
return { assignments };
|
|
273
281
|
}
|
|
274
282
|
export function validateBudgetUpdatePayload(value) {
|
|
275
283
|
const payload = requireObject(value, 'budget update payload');
|
|
@@ -505,6 +513,73 @@ function isAssignmentResponse(value) {
|
|
|
505
513
|
&& typeof item.error === 'string'
|
|
506
514
|
&& (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
|
|
507
515
|
}
|
|
516
|
+
function isAssignmentOperationResult(value) {
|
|
517
|
+
if (!isObject(value) || typeof value.transactionRef !== 'string')
|
|
518
|
+
return false;
|
|
519
|
+
const { status, ...legacyResult } = value;
|
|
520
|
+
if (status === 'succeeded') {
|
|
521
|
+
return isAssignmentResponse({ succeeded: [legacyResult], failed: [] });
|
|
522
|
+
}
|
|
523
|
+
if (status === 'failed') {
|
|
524
|
+
return isAssignmentResponse({ succeeded: [], failed: [legacyResult] });
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
export function parseAssignmentOperationResponse(value) {
|
|
529
|
+
const validBase = isObject(value)
|
|
530
|
+
&& hasOnlyFields(value, [
|
|
531
|
+
'operationId', 'status', 'itemCount', 'completedCount', 'failedCount',
|
|
532
|
+
'expiresAt', 'pollAfterMs', 'results',
|
|
533
|
+
])
|
|
534
|
+
&& typeof value.operationId === 'string'
|
|
535
|
+
&& /^[a-f0-9]{64}$/.test(value.operationId)
|
|
536
|
+
&& (value.status === 'pending'
|
|
537
|
+
|| value.status === 'processing'
|
|
538
|
+
|| value.status === 'completed')
|
|
539
|
+
&& Number.isSafeInteger(value.itemCount)
|
|
540
|
+
&& Number(value.itemCount) >= 1
|
|
541
|
+
&& Number(value.itemCount) <= 100
|
|
542
|
+
&& isNonnegativeSafeInteger(value.completedCount)
|
|
543
|
+
&& Number(value.completedCount) <= Number(value.itemCount)
|
|
544
|
+
&& isNonnegativeSafeInteger(value.failedCount)
|
|
545
|
+
&& Number(value.failedCount) <= Number(value.completedCount)
|
|
546
|
+
&& isIsoDateTime(value.expiresAt)
|
|
547
|
+
&& isNonnegativeSafeInteger(value.pollAfterMs)
|
|
548
|
+
&& Number(value.pollAfterMs) >= 100
|
|
549
|
+
&& Number(value.pollAfterMs) <= 10_000;
|
|
550
|
+
if (!validBase) {
|
|
551
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
552
|
+
}
|
|
553
|
+
const isComplete = value.status === 'completed';
|
|
554
|
+
const resultsAreValid = isComplete
|
|
555
|
+
? (Number(value.completedCount) === Number(value.itemCount)
|
|
556
|
+
&& Array.isArray(value.results)
|
|
557
|
+
&& value.results.length === Number(value.itemCount)
|
|
558
|
+
&& value.results.every(isAssignmentOperationResult)
|
|
559
|
+
&& value.results.filter((result) => result.status === 'failed').length
|
|
560
|
+
=== Number(value.failedCount))
|
|
561
|
+
: value.results === undefined;
|
|
562
|
+
if (!resultsAreValid) {
|
|
563
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
564
|
+
}
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
export function toLegacyAssignmentResponse(value) {
|
|
568
|
+
const operation = parseAssignmentOperationResponse(value);
|
|
569
|
+
if (operation.status !== 'completed' || !operation.results) {
|
|
570
|
+
throw new ApiError('Assignment operation is not complete');
|
|
571
|
+
}
|
|
572
|
+
const succeeded = [];
|
|
573
|
+
const failed = [];
|
|
574
|
+
for (const result of operation.results) {
|
|
575
|
+
const { status, ...legacyResult } = result;
|
|
576
|
+
if (status === 'succeeded')
|
|
577
|
+
succeeded.push(legacyResult);
|
|
578
|
+
else
|
|
579
|
+
failed.push(legacyResult);
|
|
580
|
+
}
|
|
581
|
+
return { succeeded, failed };
|
|
582
|
+
}
|
|
508
583
|
function isHttpUrl(value) {
|
|
509
584
|
if (typeof value !== 'string')
|
|
510
585
|
return false;
|