@slothmoney/agent-cli 0.1.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 ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-07-18
4
+
5
+ - Publish the first installable Sloth Agent CLI.
6
+ - Read categories and filtered transactions from the Agent API.
7
+ - Preview or apply transaction assignments.
8
+ - Create partner clarification links.
9
+ - Validate commands, payloads, API responses, and production URL safety.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rath Bala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Sloth Agent CLI
2
+
3
+ Use your own agent to read and categorise transactions through the
4
+ [Sloth Money Agent API](https://slothmoney.app/developers/).
5
+
6
+ ## Install
7
+
8
+ The CLI requires Node.js 22 or newer.
9
+
10
+ ```bash
11
+ npm install --global @slothmoney/agent-cli
12
+ sloth-agent --version
13
+ ```
14
+
15
+ For a one-off pinned run:
16
+
17
+ ```bash
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.1.0 -- sloth-agent --help
19
+ ```
20
+
21
+ ## Authenticate
22
+
23
+ Create a personal access token in Sloth Money under
24
+ **Settings > Developer access**. Load it from your environment or secret
25
+ manager:
26
+
27
+ ```bash
28
+ export SLOTH_AGENT_TOKEN="sloth_pat_v1_..."
29
+ ```
30
+
31
+ Do not paste the token into prompts, chat, source control, shared logs, or
32
+ assignment files. The CLI reads the token from the environment, sends it only
33
+ as an HTTPS bearer token, and never stores it.
34
+
35
+ ## Commands
36
+
37
+ Read categories and available budget line items:
38
+
39
+ ```bash
40
+ sloth-agent categories
41
+ ```
42
+
43
+ Read uncategorised transactions:
44
+
45
+ ```bash
46
+ sloth-agent transactions --uncategorized --limit 50
47
+ ```
48
+
49
+ Search a date range:
50
+
51
+ ```bash
52
+ sloth-agent transactions \
53
+ --q "tesco" \
54
+ --start-date 2026-05-01 \
55
+ --end-date 2026-05-31
56
+ ```
57
+
58
+ Preview an assignment file without writing:
59
+
60
+ ```bash
61
+ sloth-agent assign --input assignments.json
62
+ ```
63
+
64
+ Apply the same file:
65
+
66
+ ```bash
67
+ sloth-agent assign --input assignments.json --apply
68
+ ```
69
+
70
+ Create a partner clarification link:
71
+
72
+ ```bash
73
+ sloth-agent ask-partner --transaction-ref sloth_txn_...
74
+ ```
75
+
76
+ Assignment files use the Agent API request shape:
77
+
78
+ ```json
79
+ {
80
+ "assignments": [
81
+ {
82
+ "transactionRef": "sloth_txn_...",
83
+ "categoryId": "groceries",
84
+ "lineItemId": "weekly"
85
+ }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ## Configuration and output
91
+
92
+ The CLI defaults to `https://budget.slothmoney.app`. For local development,
93
+ set `SLOTH_AGENT_API_BASE_URL=http://localhost:4000` or pass
94
+ `--base-url http://localhost:4000`. Non-local HTTP origins are rejected so a
95
+ token cannot be sent over an unencrypted connection.
96
+
97
+ Command results are JSON on stdout. Diagnostics are written to stderr.
98
+
99
+ | Exit code | Meaning |
100
+ | --- | --- |
101
+ | `0` | Success |
102
+ | `1` | API, network, response-validation, or partial assignment failure |
103
+ | `2` | Invalid command, option, URL, date, or assignment input |
104
+ | `3` | Missing required configuration |
105
+
106
+ Assignment writes are best-effort. A response containing any failed assignment
107
+ returns exit code `1` while preserving the complete API response on stdout.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ npm ci
113
+ npm run verify
114
+ ```
115
+
116
+ `npm run test:package` packs the exact npm artifact, installs it into a clean
117
+ temporary project, and runs the installed binary.
package/dist/args.js ADDED
@@ -0,0 +1,212 @@
1
+ import { UsageError } from './errors.js';
2
+ const PRODUCTION_BASE_URL = 'https://budget.slothmoney.app';
3
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
4
+ function readOptionValue(args, index, name) {
5
+ const value = args[index + 1];
6
+ if (value === undefined || value.length === 0 || value.startsWith('--')) {
7
+ throw new UsageError(`${name} requires a value`);
8
+ }
9
+ return value;
10
+ }
11
+ function setOnce(current, value, name) {
12
+ if (current !== undefined) {
13
+ throw new UsageError(`${name} may only be provided once`);
14
+ }
15
+ return value;
16
+ }
17
+ function parseGlobalOptions(argv) {
18
+ const args = [...argv];
19
+ let baseUrl;
20
+ for (let index = 0; index < args.length; index += 1) {
21
+ const argument = args[index];
22
+ if (argument === '--base-url') {
23
+ baseUrl = setOnce(baseUrl, readOptionValue(args, index, '--base-url'), '--base-url');
24
+ args.splice(index, 2);
25
+ index -= 1;
26
+ continue;
27
+ }
28
+ if (argument?.startsWith('--base-url=')) {
29
+ const value = argument.slice('--base-url='.length);
30
+ if (!value)
31
+ throw new UsageError('--base-url requires a value');
32
+ baseUrl = setOnce(baseUrl, value, '--base-url');
33
+ args.splice(index, 1);
34
+ index -= 1;
35
+ }
36
+ }
37
+ return baseUrl === undefined ? { args } : { args, baseUrl };
38
+ }
39
+ function isValidDate(value) {
40
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
41
+ return false;
42
+ const parsed = new Date(`${value}T00:00:00.000Z`);
43
+ return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
44
+ }
45
+ function requireNonEmpty(value, name) {
46
+ if (!value.trim())
47
+ throw new UsageError(`${name} requires a value`);
48
+ return value;
49
+ }
50
+ function parseTransactions(args) {
51
+ const filters = {};
52
+ for (let index = 0; index < args.length; index += 1) {
53
+ const argument = args[index];
54
+ if (argument === '--uncategorized') {
55
+ filters.uncategorized = setOnce(filters.uncategorized, true, '--uncategorized');
56
+ continue;
57
+ }
58
+ if (argument.startsWith('--uncategorized=')) {
59
+ const value = argument.slice('--uncategorized='.length);
60
+ if (value !== 'true' && value !== 'false') {
61
+ throw new UsageError('--uncategorized must be true or false');
62
+ }
63
+ filters.uncategorized = setOnce(filters.uncategorized, value === 'true', '--uncategorized');
64
+ continue;
65
+ }
66
+ const [name, inlineValue] = argument.includes('=')
67
+ ? argument.split(/=(.*)/s, 2)
68
+ : [argument, undefined];
69
+ const supported = new Set([
70
+ '--limit',
71
+ '--start-date',
72
+ '--end-date',
73
+ '--q',
74
+ '--account-id',
75
+ '--category-id',
76
+ '--cursor',
77
+ ]);
78
+ if (!name || !supported.has(name)) {
79
+ throw new UsageError(`Unknown transactions option: ${argument}`);
80
+ }
81
+ const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, name), name);
82
+ if (inlineValue === undefined)
83
+ index += 1;
84
+ if (name === '--limit') {
85
+ const limit = Number(value);
86
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
87
+ throw new UsageError('--limit must be an integer between 1 and 200');
88
+ }
89
+ filters.limit = setOnce(filters.limit, limit, name);
90
+ }
91
+ else if (name === '--start-date' || name === '--end-date') {
92
+ if (!isValidDate(value)) {
93
+ throw new UsageError(`${name} must be a valid YYYY-MM-DD date`);
94
+ }
95
+ if (name === '--start-date') {
96
+ filters.startDate = setOnce(filters.startDate, value, name);
97
+ }
98
+ else {
99
+ filters.endDate = setOnce(filters.endDate, value, name);
100
+ }
101
+ }
102
+ else if (name === '--q') {
103
+ filters.q = setOnce(filters.q, value, name);
104
+ }
105
+ else if (name === '--account-id') {
106
+ filters.accountId = setOnce(filters.accountId, value, name);
107
+ }
108
+ else if (name === '--category-id') {
109
+ filters.categoryId = setOnce(filters.categoryId, value, name);
110
+ }
111
+ else if (name === '--cursor') {
112
+ filters.cursor = setOnce(filters.cursor, value, name);
113
+ }
114
+ }
115
+ if (filters.startDate !== undefined
116
+ && filters.endDate !== undefined
117
+ && filters.endDate < filters.startDate) {
118
+ throw new UsageError('--end-date must not be before --start-date');
119
+ }
120
+ return filters;
121
+ }
122
+ function withBaseUrl(value, baseUrl) {
123
+ return baseUrl === undefined ? value : { ...value, baseUrl };
124
+ }
125
+ export function parseArgs(argv) {
126
+ if (argv.includes('--help') || argv.includes('-h'))
127
+ return { command: 'help' };
128
+ if (argv.includes('--version') || argv.includes('-V'))
129
+ return { command: 'version' };
130
+ const { args, baseUrl } = parseGlobalOptions(argv);
131
+ const command = args.shift();
132
+ if (!command)
133
+ return { command: 'help' };
134
+ if (command === 'categories') {
135
+ if (args.length > 0) {
136
+ throw new UsageError(`Unknown categories option: ${args[0]}`);
137
+ }
138
+ return withBaseUrl({ command }, baseUrl);
139
+ }
140
+ if (command === 'transactions') {
141
+ return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
142
+ }
143
+ if (command === 'assign') {
144
+ let input;
145
+ let apply = false;
146
+ for (let index = 0; index < args.length; index += 1) {
147
+ const argument = args[index];
148
+ if (argument === '--apply') {
149
+ if (apply)
150
+ throw new UsageError('--apply may only be provided once');
151
+ apply = true;
152
+ }
153
+ else if (argument === '--input') {
154
+ input = setOnce(input, readOptionValue(args, index, '--input'), '--input');
155
+ index += 1;
156
+ }
157
+ else if (argument.startsWith('--input=')) {
158
+ input = setOnce(input, requireNonEmpty(argument.slice('--input='.length), '--input'), '--input');
159
+ }
160
+ else {
161
+ throw new UsageError(`Unknown assign option: ${argument}`);
162
+ }
163
+ }
164
+ if (!input)
165
+ throw new UsageError('assign requires --input <file>');
166
+ return withBaseUrl({ command, input, apply }, baseUrl);
167
+ }
168
+ if (command === 'ask-partner') {
169
+ let transactionRef;
170
+ for (let index = 0; index < args.length; index += 1) {
171
+ const argument = args[index];
172
+ if (argument === '--transaction-ref') {
173
+ transactionRef = setOnce(transactionRef, readOptionValue(args, index, '--transaction-ref'), '--transaction-ref');
174
+ index += 1;
175
+ }
176
+ else if (argument.startsWith('--transaction-ref=')) {
177
+ transactionRef = setOnce(transactionRef, requireNonEmpty(argument.slice('--transaction-ref='.length), '--transaction-ref'), '--transaction-ref');
178
+ }
179
+ else {
180
+ throw new UsageError(`Unknown ask-partner option: ${argument}`);
181
+ }
182
+ }
183
+ if (!transactionRef) {
184
+ throw new UsageError('ask-partner requires --transaction-ref <ref>');
185
+ }
186
+ return withBaseUrl({ command, transactionRef }, baseUrl);
187
+ }
188
+ throw new UsageError(`Unknown command: ${command}`);
189
+ }
190
+ export function resolveBaseUrl(environment, override) {
191
+ const rawValue = override || environment.SLOTH_AGENT_API_BASE_URL || PRODUCTION_BASE_URL;
192
+ let url;
193
+ try {
194
+ url = new URL(rawValue);
195
+ }
196
+ catch {
197
+ throw new UsageError('--base-url must be a valid URL');
198
+ }
199
+ if (url.username || url.password) {
200
+ throw new UsageError('--base-url must not include credentials');
201
+ }
202
+ if (url.pathname !== '/' || url.search || url.hash) {
203
+ throw new UsageError('--base-url must be an origin only');
204
+ }
205
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))) {
206
+ throw new UsageError('--base-url must use HTTPS unless it targets localhost');
207
+ }
208
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
209
+ throw new UsageError('--base-url must use HTTPS unless it targets localhost');
210
+ }
211
+ return url.origin;
212
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from './cli.js';
3
+ process.exitCode = await runCli();
package/dist/cli.js ADDED
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import { parseArgs, resolveBaseUrl, } from './args.js';
3
+ import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
4
+ import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
5
+ export const CLI_VERSION = '0.1.0';
6
+ const REQUEST_TIMEOUT_MS = 30_000;
7
+ export function usageText() {
8
+ return [
9
+ 'Sloth Agent CLI',
10
+ '',
11
+ 'Usage:',
12
+ ' sloth-agent categories [--base-url URL]',
13
+ ' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
14
+ ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
15
+ ' [--account-id ID] [--category-id ID] [--cursor CURSOR] [--base-url URL]',
16
+ ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
17
+ ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
18
+ ' sloth-agent --help',
19
+ ' sloth-agent --version',
20
+ '',
21
+ 'Environment:',
22
+ ' SLOTH_AGENT_TOKEN Personal access token from Settings > Developer access',
23
+ ' SLOTH_AGENT_API_BASE_URL Optional API origin; defaults to https://budget.slothmoney.app',
24
+ ].join('\n');
25
+ }
26
+ function writeJson(write, data) {
27
+ write(`${JSON.stringify(data, null, 2)}\n`);
28
+ }
29
+ function requireToken(environment) {
30
+ const token = environment.SLOTH_AGENT_TOKEN;
31
+ if (!token?.trim())
32
+ throw new ConfigError('SLOTH_AGENT_TOKEN is required');
33
+ return token;
34
+ }
35
+ function redact(value, token) {
36
+ return token ? value.split(token).join('[REDACTED]') : value;
37
+ }
38
+ function readAssignmentFile(filePath) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
41
+ }
42
+ catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ throw new UsageError(`Failed to read assignment JSON: ${message}`);
45
+ }
46
+ }
47
+ function buildTransactionsQuery(filters) {
48
+ const params = new URLSearchParams();
49
+ if (filters.uncategorized !== undefined) {
50
+ params.set('uncategorized', String(filters.uncategorized));
51
+ }
52
+ if (filters.limit !== undefined)
53
+ params.set('limit', String(filters.limit));
54
+ if (filters.startDate !== undefined)
55
+ params.set('startDate', filters.startDate);
56
+ if (filters.endDate !== undefined)
57
+ params.set('endDate', filters.endDate);
58
+ if (filters.q !== undefined)
59
+ params.set('q', filters.q);
60
+ if (filters.accountId !== undefined)
61
+ params.set('accountId', filters.accountId);
62
+ if (filters.categoryId !== undefined)
63
+ params.set('categoryId', filters.categoryId);
64
+ if (filters.cursor !== undefined)
65
+ params.set('cursor', filters.cursor);
66
+ return params.toString();
67
+ }
68
+ async function parseHttpResponse(response, token) {
69
+ const text = await response.text();
70
+ let data = null;
71
+ if (text) {
72
+ try {
73
+ data = JSON.parse(text);
74
+ }
75
+ catch {
76
+ if (response.ok)
77
+ throw new ApiError('Agent API returned invalid JSON');
78
+ }
79
+ }
80
+ if (!response.ok) {
81
+ const message = (data
82
+ && typeof data === 'object'
83
+ && 'error' in data
84
+ && typeof data.error === 'string')
85
+ ? data.error
86
+ : `Agent API request failed with status ${response.status}`;
87
+ throw new ApiError(redact(message, token));
88
+ }
89
+ return data;
90
+ }
91
+ function hasFailures(value) {
92
+ if (!value || typeof value !== 'object' || !('failed' in value))
93
+ return false;
94
+ return Array.isArray(value.failed) && value.failed.length > 0;
95
+ }
96
+ export async function runCli(argv = process.argv.slice(2), options = {}) {
97
+ const environment = options.env ?? process.env;
98
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
99
+ const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
100
+ const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
101
+ let token;
102
+ try {
103
+ const parsed = parseArgs(argv);
104
+ if (parsed.command === 'help') {
105
+ writeStdout(`${usageText()}\n`);
106
+ return 0;
107
+ }
108
+ if (parsed.command === 'version') {
109
+ writeStdout(`${CLI_VERSION}\n`);
110
+ return 0;
111
+ }
112
+ token = requireToken(environment);
113
+ const baseUrl = resolveBaseUrl(environment, parsed.baseUrl);
114
+ const headers = {
115
+ Accept: 'application/json',
116
+ Authorization: `Bearer ${token}`,
117
+ 'User-Agent': `sloth-agent/${CLI_VERSION}`,
118
+ };
119
+ if (parsed.command === 'assign') {
120
+ const payload = validateAssignmentPayload(readAssignmentFile(parsed.input));
121
+ const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
122
+ if (!parsed.apply) {
123
+ writeJson(writeStdout, { dryRun: true, endpoint, payload });
124
+ return 0;
125
+ }
126
+ const response = await fetchImplementation(endpoint, {
127
+ method: 'POST',
128
+ headers: {
129
+ ...headers,
130
+ 'Content-Type': 'application/json',
131
+ },
132
+ body: JSON.stringify(payload),
133
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
134
+ });
135
+ const data = parseApiResponse('assign', await parseHttpResponse(response, token));
136
+ writeJson(writeStdout, data);
137
+ return hasFailures(data) ? 1 : 0;
138
+ }
139
+ if (parsed.command === 'ask-partner') {
140
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/transaction-explanation-requests`, {
141
+ method: 'POST',
142
+ headers: {
143
+ ...headers,
144
+ 'Content-Type': 'application/json',
145
+ },
146
+ body: JSON.stringify({ transactionRef: parsed.transactionRef }),
147
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
148
+ });
149
+ const data = parseApiResponse('ask-partner', await parseHttpResponse(response, token));
150
+ writeJson(writeStdout, data);
151
+ return 0;
152
+ }
153
+ const path = parsed.command === 'categories'
154
+ ? '/api/agent/v1/categories'
155
+ : `/api/agent/v1/transactions${(() => {
156
+ const query = buildTransactionsQuery(parsed.filters);
157
+ return query ? `?${query}` : '';
158
+ })()}`;
159
+ const response = await fetchImplementation(`${baseUrl}${path}`, {
160
+ method: 'GET',
161
+ headers,
162
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
163
+ });
164
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
165
+ writeJson(writeStdout, data);
166
+ return 0;
167
+ }
168
+ catch (error) {
169
+ const exitCode = error instanceof CliError ? error.exitCode : 1;
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ writeStderr(`${redact(message, token)}\n`);
172
+ return exitCode;
173
+ }
174
+ }
@@ -0,0 +1,191 @@
1
+ import { ApiError, UsageError, } from './errors.js';
2
+ function isObject(value) {
3
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
4
+ }
5
+ function requireObject(value, label) {
6
+ if (!isObject(value))
7
+ throw new UsageError(`${label} must be an object`);
8
+ return value;
9
+ }
10
+ function requireString(value, label) {
11
+ if (typeof value !== 'string' || value.trim().length === 0) {
12
+ throw new UsageError(`${label} is required`);
13
+ }
14
+ return value;
15
+ }
16
+ function rejectUnknownFields(value, allowed, label) {
17
+ const unknown = Object.keys(value).find((key) => !allowed.has(key));
18
+ if (unknown)
19
+ throw new UsageError(`${label} contains unknown field: ${unknown}`);
20
+ }
21
+ function validateSplit(value, index, splitIndex) {
22
+ const label = `assignments[${index}].categorySplits[${splitIndex}]`;
23
+ const split = requireObject(value, label);
24
+ rejectUnknownFields(split, new Set(['categoryId', 'amountPence', 'lineItemId']), label);
25
+ const categoryId = requireString(split.categoryId, `${label}.categoryId`);
26
+ if (!Number.isInteger(split.amountPence) || Number(split.amountPence) <= 0) {
27
+ throw new UsageError(`${label}.amountPence must be a positive integer`);
28
+ }
29
+ if (split.lineItemId !== undefined) {
30
+ requireString(split.lineItemId, `${label}.lineItemId`);
31
+ }
32
+ return typeof split.lineItemId === 'string'
33
+ ? { categoryId, amountPence: Number(split.amountPence), lineItemId: split.lineItemId }
34
+ : { categoryId, amountPence: Number(split.amountPence) };
35
+ }
36
+ function validateAssignment(value, index) {
37
+ const label = `assignments[${index}]`;
38
+ const assignment = requireObject(value, label);
39
+ rejectUnknownFields(assignment, new Set([
40
+ 'transactionRef',
41
+ 'categoryId',
42
+ 'lineItemId',
43
+ 'categorySplits',
44
+ 'incomeSubtype',
45
+ ]), label);
46
+ requireString(assignment.transactionRef, `${label}.transactionRef`);
47
+ if (assignment.categoryId !== undefined && assignment.categoryId !== null) {
48
+ requireString(assignment.categoryId, `${label}.categoryId`);
49
+ }
50
+ if (assignment.lineItemId !== undefined && assignment.lineItemId !== null) {
51
+ requireString(assignment.lineItemId, `${label}.lineItemId`);
52
+ }
53
+ if (assignment.incomeSubtype !== undefined
54
+ && assignment.incomeSubtype !== null
55
+ && assignment.incomeSubtype !== 'pay'
56
+ && assignment.incomeSubtype !== 'interest') {
57
+ throw new UsageError(`${label}.incomeSubtype must be pay, interest, or null`);
58
+ }
59
+ let categorySplits;
60
+ if (assignment.categorySplits === null) {
61
+ categorySplits = null;
62
+ }
63
+ else if (assignment.categorySplits !== undefined) {
64
+ if (!Array.isArray(assignment.categorySplits) || assignment.categorySplits.length === 0) {
65
+ throw new UsageError(`${label}.categorySplits must be a non-empty array or null`);
66
+ }
67
+ categorySplits = assignment.categorySplits.map((split, splitIndex) => (validateSplit(split, index, splitIndex)));
68
+ }
69
+ const hasCategory = typeof assignment.categoryId === 'string' && assignment.categoryId.trim().length > 0;
70
+ const isClear = assignment.categoryId === null && (!categorySplits || categorySplits.length === 0);
71
+ const hasSplits = Array.isArray(categorySplits) && categorySplits.length > 0;
72
+ if (!hasCategory && !isClear && !hasSplits) {
73
+ throw new UsageError(`${label}.categoryId or categorySplits is required`);
74
+ }
75
+ return {
76
+ transactionRef: assignment.transactionRef,
77
+ ...(assignment.categoryId !== undefined ? { categoryId: assignment.categoryId } : {}),
78
+ ...(assignment.lineItemId !== undefined ? { lineItemId: assignment.lineItemId } : {}),
79
+ ...(categorySplits !== undefined ? { categorySplits } : {}),
80
+ ...(assignment.incomeSubtype !== undefined
81
+ ? { incomeSubtype: assignment.incomeSubtype }
82
+ : {}),
83
+ };
84
+ }
85
+ export function validateAssignmentPayload(value) {
86
+ const payload = requireObject(value, 'assignment payload');
87
+ rejectUnknownFields(payload, new Set(['assignments']), 'assignment payload');
88
+ if (!Array.isArray(payload.assignments)) {
89
+ throw new UsageError('assignments array is required');
90
+ }
91
+ if (payload.assignments.length < 1 || payload.assignments.length > 100) {
92
+ throw new UsageError('assignments must contain between 1 and 100 items');
93
+ }
94
+ return { assignments: payload.assignments.map(validateAssignment) };
95
+ }
96
+ function isLineItemMap(value) {
97
+ if (!isObject(value))
98
+ return false;
99
+ return Object.values(value).every((items) => (Array.isArray(items)
100
+ && items.every((item) => (isObject(item)
101
+ && typeof item.id === 'string'
102
+ && item.id.length > 0
103
+ && typeof item.name === 'string'
104
+ && item.name.length > 0))));
105
+ }
106
+ function isCategoryResponse(value) {
107
+ return (isObject(value)
108
+ && Array.isArray(value.categories)
109
+ && value.categories.every((category) => (isObject(category)
110
+ && typeof category.id === 'string'
111
+ && typeof category.name === 'string'
112
+ && (category.source === 'default' || category.source === 'user')))
113
+ && isLineItemMap(value.personalLineItemsByCategoryId)
114
+ && isLineItemMap(value.jointLineItemsByCategoryId));
115
+ }
116
+ function isTransaction(value) {
117
+ return (isObject(value)
118
+ && typeof value.transactionRef === 'string'
119
+ && typeof value.id === 'string'
120
+ && typeof value.name === 'string'
121
+ && typeof value.amount === 'number'
122
+ && Number.isFinite(value.amount)
123
+ && typeof value.currency === 'string'
124
+ && typeof value.date === 'string'
125
+ && value.status === 'booked'
126
+ && typeof value.accountId === 'string'
127
+ && typeof value.accountDocId === 'string'
128
+ && typeof value.requisitionId === 'string'
129
+ && (value.scope === 'personal' || value.scope === 'joint')
130
+ && (value.categoryId === null || typeof value.categoryId === 'string')
131
+ && (value.lineItemId === null || typeof value.lineItemId === 'string')
132
+ && Array.isArray(value.categorySplits)
133
+ && (value.incomeSubtype === null
134
+ || value.incomeSubtype === 'pay'
135
+ || value.incomeSubtype === 'interest'));
136
+ }
137
+ function isTransactionsResponse(value) {
138
+ return (isObject(value)
139
+ && Array.isArray(value.transactions)
140
+ && value.transactions.every(isTransaction)
141
+ && (value.nextCursor === null || typeof value.nextCursor === 'string'));
142
+ }
143
+ function isAssignmentResponse(value) {
144
+ return (isObject(value)
145
+ && Array.isArray(value.succeeded)
146
+ && value.succeeded.every((item) => isObject(item) && typeof item.transactionRef === 'string')
147
+ && Array.isArray(value.failed)
148
+ && value.failed.every((item) => (isObject(item)
149
+ && typeof item.error === 'string'
150
+ && (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
151
+ }
152
+ function isHttpUrl(value) {
153
+ if (typeof value !== 'string')
154
+ return false;
155
+ try {
156
+ const parsed = new URL(value);
157
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:';
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ function isIsoDateTime(value) {
164
+ if (typeof value !== 'string')
165
+ return false;
166
+ const parsed = new Date(value);
167
+ return (!Number.isNaN(parsed.valueOf())
168
+ && parsed.toISOString() === value);
169
+ }
170
+ function isPartnerResponse(value) {
171
+ return (isObject(value)
172
+ && typeof value.requestId === 'string'
173
+ && isHttpUrl(value.publicUrl)
174
+ && typeof value.message === 'string'
175
+ && isIsoDateTime(value.expiresAt)
176
+ && value.status === 'open');
177
+ }
178
+ export function parseApiResponse(command, value) {
179
+ const valid = command === 'categories'
180
+ ? isCategoryResponse(value)
181
+ : command === 'transactions'
182
+ ? isTransactionsResponse(value)
183
+ : command === 'assign'
184
+ ? isAssignmentResponse(value)
185
+ : isPartnerResponse(value);
186
+ if (!valid) {
187
+ const label = command === 'assign' ? 'assignment' : command;
188
+ throw new ApiError(`Invalid ${label} response from the Agent API`);
189
+ }
190
+ return value;
191
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ export class CliError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode) {
4
+ super(message);
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
8
+ export class UsageError extends CliError {
9
+ constructor(message) {
10
+ super(message, 2);
11
+ }
12
+ }
13
+ export class ConfigError extends CliError {
14
+ constructor(message) {
15
+ super(message, 3);
16
+ }
17
+ }
18
+ export class ApiError extends CliError {
19
+ constructor(message) {
20
+ super(message, 1);
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@slothmoney/agent-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line access to the Sloth Money Agent API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sloth-agent": "dist/bin.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "CHANGELOG.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "lint": "eslint .",
16
+ "prepack": "npm run build",
17
+ "test": "vitest run",
18
+ "test:package": "node scripts/test-package.mjs",
19
+ "typecheck": "tsc --noEmit",
20
+ "verify": "npm run lint && npm run typecheck && npm test && npm run build && npm run test:package"
21
+ },
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/RathBala/sloth-agent-cli.git"
32
+ },
33
+ "homepage": "https://slothmoney.app/developers/",
34
+ "bugs": {
35
+ "url": "https://github.com/RathBala/sloth-agent-cli/issues"
36
+ },
37
+ "keywords": [
38
+ "sloth-money",
39
+ "personal-finance",
40
+ "agent",
41
+ "cli"
42
+ ],
43
+ "author": "Rath Bala",
44
+ "license": "MIT",
45
+ "packageManager": "npm@11.18.0",
46
+ "devDependencies": {
47
+ "@eslint/js": "^9.30.1",
48
+ "@types/node": "^22.15.0",
49
+ "eslint": "^9.30.1",
50
+ "typescript": "^5.8.3",
51
+ "typescript-eslint": "^8.35.1",
52
+ "vitest": "^3.2.4"
53
+ }
54
+ }