@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 +9 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/args.js +212 -0
- package/dist/bin.js +3 -0
- package/dist/cli.js +174 -0
- package/dist/contracts.js +191 -0
- package/dist/errors.js +22 -0
- package/package.json +54 -0
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
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
|
+
}
|