@slothmoney/agent-cli 0.1.0 → 0.3.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/dist/cli.js CHANGED
@@ -1,40 +1,563 @@
1
1
  import fs from 'node:fs';
2
2
  import { parseArgs, resolveBaseUrl, } from './args.js';
3
- import { parseApiResponse, validateAssignmentPayload, } from './contracts.js';
3
+ import { parseApiResponse, validateAssignmentPayload, validateJointBudgetSettingsResponse, } from './contracts.js';
4
+ import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
4
5
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
5
- export const CLI_VERSION = '0.1.0';
6
+ export const CLI_VERSION = '0.3.0';
6
7
  const REQUEST_TIMEOUT_MS = 30_000;
8
+ const API_ORIGIN_HELP_LINES = [
9
+ '',
10
+ 'API origin:',
11
+ ' --base-url overrides SLOTH_AGENT_API_BASE_URL; if neither is set, the',
12
+ ' origin defaults to https://budget.slothmoney.app.',
13
+ ' Use an origin-only URL with no credentials, path, query, or fragment.',
14
+ ' HTTPS is required except for localhost development.',
15
+ ];
7
16
  export function usageText() {
8
17
  return [
9
18
  'Sloth Agent CLI',
10
19
  '',
11
20
  'Usage:',
21
+ ' sloth-agent <command> [options]',
22
+ '',
23
+ 'Commands:',
24
+ ' sloth-agent auth login [--token-stdin | --from-env] [--base-url URL]',
25
+ ' sloth-agent auth status [--base-url URL]',
26
+ ' sloth-agent auth logout [--base-url URL]',
12
27
  ' sloth-agent categories [--base-url URL]',
13
28
  ' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
14
29
  ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
15
30
  ' [--account-id ID] [--category-id ID] [--cursor CURSOR] [--base-url URL]',
16
31
  ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
32
+ ' sloth-agent joint-budget-settings [--include-shared-personal-transactions=true|false] [--apply]',
33
+ ' sloth-agent goals [list] [--base-url URL]',
34
+ ' sloth-agent goals create --name NAME [--target-amount AMOUNT]',
35
+ ' [--target-month YYYY-MM] [--apply] [--base-url URL]',
36
+ ' sloth-agent goals update --goal-id ID [fields] [--apply] [--base-url URL]',
37
+ ' sloth-agent goals delete --goal-id ID [--apply] [--base-url URL]',
17
38
  ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
18
- ' sloth-agent --help',
19
- ' sloth-agent --version',
39
+ '',
40
+ 'Help:',
41
+ ' Run sloth-agent <command> --help for options, inputs, output, and examples.',
42
+ ' Auth and goal subcommands also have help, for example:',
43
+ ' sloth-agent auth login --help',
44
+ ' sloth-agent goals update --help',
45
+ '',
46
+ 'Global options:',
47
+ ' -h, --help Show top-level or command-specific help',
48
+ ' -V, --version Show the CLI version',
49
+ ' --base-url URL Use a different API origin',
20
50
  '',
21
51
  'Environment:',
22
52
  ' SLOTH_AGENT_TOKEN Personal access token from Settings > Developer access',
23
53
  ' SLOTH_AGENT_API_BASE_URL Optional API origin; defaults to https://budget.slothmoney.app',
24
54
  ].join('\n');
25
55
  }
56
+ export function authHelpText() {
57
+ return [
58
+ 'Sloth Agent CLI — auth',
59
+ '',
60
+ 'Manage the personal access token used for Agent API requests.',
61
+ '',
62
+ 'Commands:',
63
+ ' sloth-agent auth login Validate and store a personal access token',
64
+ ' sloth-agent auth status Check the active credential with a live API request',
65
+ ' sloth-agent auth logout Remove the token from the native credential store',
66
+ '',
67
+ 'Help:',
68
+ ' Run sloth-agent auth <command> --help for command-specific details.',
69
+ ].join('\n');
70
+ }
71
+ export function authLoginHelpText() {
72
+ return [
73
+ 'Sloth Agent CLI — auth login',
74
+ '',
75
+ 'Validate a personal access token, then store it in the native credential store.',
76
+ '',
77
+ 'Usage:',
78
+ ' sloth-agent auth login [--token-stdin | --from-env] [--base-url URL]',
79
+ '',
80
+ 'Options:',
81
+ ' --token-stdin Optional. Read the token from stdin.',
82
+ ' --from-env Optional. Read the token from SLOTH_AGENT_TOKEN.',
83
+ ' --base-url URL Optional. Override the API origin.',
84
+ ' -h, --help Show this help.',
85
+ '',
86
+ 'Input:',
87
+ ' With no input option, login uses a hidden prompt and requires an interactive TTY.',
88
+ ' --token-stdin and --from-env are mutually exclusive.',
89
+ ' Tokens must start with sloth_pat_v1_ and contain no whitespace.',
90
+ ' Never pass a token as a command argument.',
91
+ ' An existing stored credential is replaced only after remote validation succeeds.',
92
+ ...API_ORIGIN_HELP_LINES,
93
+ '',
94
+ 'Examples:',
95
+ ' sloth-agent auth login',
96
+ ' sloth-agent auth login --token-stdin',
97
+ ' sloth-agent auth login --from-env',
98
+ '',
99
+ 'Output:',
100
+ ' JSON describing the API origin, stored state, and active credential source.',
101
+ ].join('\n');
102
+ }
103
+ export function authStatusHelpText() {
104
+ return [
105
+ 'Sloth Agent CLI — auth status',
106
+ '',
107
+ 'Check the active credential with a live API request.',
108
+ '',
109
+ 'Usage:',
110
+ ' sloth-agent auth status [--base-url URL]',
111
+ '',
112
+ 'Options:',
113
+ ' --base-url URL Optional. Override the API origin.',
114
+ ' -h, --help Show this help.',
115
+ ...API_ORIGIN_HELP_LINES,
116
+ '',
117
+ 'Example:',
118
+ ' sloth-agent auth status',
119
+ '',
120
+ 'Output:',
121
+ ' JSON containing origin, source, a masked token suffix, and remoteStatus.',
122
+ ' Checking status updates the token last-used time.',
123
+ ].join('\n');
124
+ }
125
+ export function authLogoutHelpText() {
126
+ return [
127
+ 'Sloth Agent CLI — auth logout',
128
+ '',
129
+ 'Remove the token for an API origin from the native credential store.',
130
+ '',
131
+ 'Usage:',
132
+ ' sloth-agent auth logout [--base-url URL]',
133
+ '',
134
+ 'Options:',
135
+ ' --base-url URL Optional. Override the API origin.',
136
+ ' -h, --help Show this help.',
137
+ ...API_ORIGIN_HELP_LINES,
138
+ '',
139
+ 'Important:',
140
+ ' Logout does not revoke the token remotely or unset SLOTH_AGENT_TOKEN.',
141
+ ' Revoke the token in Sloth Money Settings > Developer access.',
142
+ '',
143
+ 'Example:',
144
+ ' sloth-agent auth logout',
145
+ '',
146
+ 'Output:',
147
+ ' JSON describing local removal, any environment override, and revocation state.',
148
+ ].join('\n');
149
+ }
150
+ export function categoriesHelpText() {
151
+ return [
152
+ 'Sloth Agent CLI — categories',
153
+ '',
154
+ 'Read categories and the personal and joint line items within them.',
155
+ '',
156
+ 'Usage:',
157
+ ' sloth-agent categories [--base-url URL]',
158
+ '',
159
+ 'Options:',
160
+ ' --base-url URL Optional. Override the API origin.',
161
+ ' -h, --help Show this help.',
162
+ ...API_ORIGIN_HELP_LINES,
163
+ '',
164
+ 'Budget taxonomy:',
165
+ ' A category is the broader parent.',
166
+ ' A line item is a child within one category.',
167
+ ' Line-item names such as "Other" may repeat. Preserve the full choice as',
168
+ ' (scope, categoryId, lineItemId), using the personal or joint line-item',
169
+ ' map matching the transaction scope.',
170
+ '',
171
+ 'Examples:',
172
+ ' sloth-agent categories',
173
+ ' Bills → Other',
174
+ ' Subscriptions → Other',
175
+ '',
176
+ 'Access:',
177
+ ' This command is read-only.',
178
+ '',
179
+ 'Output:',
180
+ ' categories Parent categories',
181
+ ' personalLineItemsByCategoryId Personal child line items keyed by category ID',
182
+ ' jointLineItemsByCategoryId Joint child line items keyed by category ID',
183
+ ].join('\n');
184
+ }
185
+ export function transactionsHelpText() {
186
+ return [
187
+ 'Sloth Agent CLI — transactions',
188
+ '',
189
+ 'Read transactions, optionally filtered or paginated.',
190
+ '',
191
+ 'Usage:',
192
+ ' sloth-agent transactions [options]',
193
+ '',
194
+ 'Options:',
195
+ ' --uncategorized[=true|false] Optional. Filter by state; with no value, use true.',
196
+ ' --limit N Optional. Integer from 1 to 200; omit for API default.',
197
+ ' --start-date YYYY-MM-DD Optional. Include transactions on or after this date.',
198
+ ' --end-date YYYY-MM-DD Optional. Include transactions on or before this date.',
199
+ ' --q TEXT Optional. Search transactions by text.',
200
+ ' --account-id ID Optional. Filter by account ID.',
201
+ ' --category-id ID Optional. Filter by category ID.',
202
+ ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
203
+ ' --cursor CURSOR Optional. Continue from a previous nextCursor.',
204
+ ' --base-url URL Optional. Override the API origin.',
205
+ ' -h, --help Show this help.',
206
+ ...API_ORIGIN_HELP_LINES,
207
+ '',
208
+ 'Constraints:',
209
+ ' All filters are omitted by default.',
210
+ ' --end-date must not be before --start-date.',
211
+ ' This command is read-only.',
212
+ '',
213
+ 'Output:',
214
+ ' JSON containing transactions and nextCursor. Use nextCursor with --cursor',
215
+ ' to request the next page. A null nextCursor means there are no more pages.',
216
+ '',
217
+ 'Examples:',
218
+ ' sloth-agent transactions --uncategorized --limit 50',
219
+ ' sloth-agent transactions --assignment-scope joint --uncategorized',
220
+ ' sloth-agent transactions --q "tesco" --start-date 2026-05-01 --end-date 2026-05-31',
221
+ ].join('\n');
222
+ }
223
+ export function assignHelpText() {
224
+ return [
225
+ 'Sloth Agent CLI — assign',
226
+ '',
227
+ 'An assignment categorises an existing transaction e.g. assigning category Groceries to a transaction.',
228
+ 'Validate, preview, or apply category assignments from a JSON file.',
229
+ '',
230
+ 'Usage:',
231
+ ' sloth-agent assign --input FILE [--apply] [--base-url URL]',
232
+ '',
233
+ 'Options:',
234
+ ' --input FILE Required. JSON assignment file containing 1 to 100 assignments.',
235
+ ' --apply Optional. Write assignments to Sloth Money.',
236
+ ' --base-url URL Optional. Override the API origin.',
237
+ ' -h, --help Show this help.',
238
+ ...API_ORIGIN_HELP_LINES,
239
+ '',
240
+ 'Safety:',
241
+ ' Without --apply, the command checks that the file is valid and returns',
242
+ ' the payload it would send. It does not contact Sloth Money, verify the',
243
+ ' transactionRef or category values, or write anything.',
244
+ ' A successful preview does not guarantee that applying it will succeed.',
245
+ ' With --apply, assignments are best-effort; any failed item makes the command',
246
+ ' exit with code 1 while the complete result remains available on stdout.',
247
+ '',
248
+ 'Input:',
249
+ ' The top-level object must contain an assignments array.',
250
+ ' Each assignment requires transactionRef and a categoryId or non-empty categorySplits.',
251
+ ' Copy the exact transactionRef from transactions output and categoryId from',
252
+ ' categories output. The example values below are placeholders.',
253
+ ' Set categoryId to null to clear an assignment.',
254
+ ' lineItemId is optional and accepts a non-empty string or null.',
255
+ ' categorySplits is optional and accepts a non-empty array or null.',
256
+ ' Each split requires categoryId and a positive integer amountPence;',
257
+ ' a split lineItemId is optional.',
258
+ ' incomeSubtype is optional and accepts "pay", "interest", or null.',
259
+ ' assignmentScope is optional and accepts "personal" or "joint".',
260
+ '',
261
+ 'Workflow:',
262
+ ' sloth-agent categories',
263
+ ' sloth-agent transactions --uncategorized --limit 50',
264
+ ' sloth-agent assign --input assignments.json Preview only',
265
+ ' sloth-agent assign --input assignments.json --apply Write assignments',
266
+ ' sloth-agent transactions --limit 50 Read back the result',
267
+ '',
268
+ 'Example:',
269
+ ' {',
270
+ ' "assignments": [',
271
+ ' {',
272
+ ' "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",',
273
+ ' "categoryId": "PASTE_A_CATEGORY_ID_HERE"',
274
+ ' }',
275
+ ' ]',
276
+ ' }',
277
+ ' These are placeholders. Replace both values with exact IDs from CLI output.',
278
+ '',
279
+ 'Output:',
280
+ ' Preview mode returns dryRun, endpoint, and the validated payload.',
281
+ ' Apply mode returns succeeded and failed assignment arrays.',
282
+ ' Successful assignments update the original transaction. See the result in',
283
+ ' Sloth Money → Transactions or read the transaction again through the CLI.',
284
+ ' Assignments do not create a separate list.',
285
+ ].join('\n');
286
+ }
287
+ export function jointBudgetSettingsHelpText() {
288
+ return [
289
+ 'Sloth Agent CLI — joint-budget-settings',
290
+ '',
291
+ 'Read or update whether shared personal transactions count in the linked joint budget.',
292
+ '',
293
+ 'Usage:',
294
+ ' sloth-agent joint-budget-settings [options]',
295
+ '',
296
+ 'Options:',
297
+ ' --include-shared-personal-transactions=true|false',
298
+ ' Optional. Preview the linked setting change.',
299
+ ' --apply Optional. Apply the previewed setting change.',
300
+ ' --base-url URL Optional. Override the API origin.',
301
+ ' -h, --help Show this help.',
302
+ ...API_ORIGIN_HELP_LINES,
303
+ '',
304
+ 'Safety:',
305
+ ' With no setting option, the command is read-only.',
306
+ ' Without --apply, a setting option returns a dry-run preview and does not write.',
307
+ ' --apply requires an explicit true or false setting value.',
308
+ '',
309
+ 'Examples:',
310
+ ' sloth-agent joint-budget-settings',
311
+ ' sloth-agent joint-budget-settings --include-shared-personal-transactions=true',
312
+ ' sloth-agent joint-budget-settings --include-shared-personal-transactions=true --apply',
313
+ '',
314
+ 'Output:',
315
+ ' JSON containing the linked setting, audit metadata, or a dry-run payload.',
316
+ ].join('\n');
317
+ }
318
+ export function goalsHelpText() {
319
+ return [
320
+ 'Sloth Agent CLI — goals',
321
+ '',
322
+ 'List, create, update, or delete your savings goals.',
323
+ '',
324
+ 'Commands:',
325
+ ' sloth-agent goals list List goals; "sloth-agent goals" is equivalent.',
326
+ ' sloth-agent goals create Preview or create a goal.',
327
+ ' sloth-agent goals update Preview or update selected goal fields.',
328
+ ' sloth-agent goals delete Preview or permanently delete a goal.',
329
+ '',
330
+ 'Help:',
331
+ ' Run sloth-agent goals <command> --help for command-specific details.',
332
+ ...API_ORIGIN_HELP_LINES,
333
+ ].join('\n');
334
+ }
335
+ export function goalsListHelpText() {
336
+ return [
337
+ 'Sloth Agent CLI — goals list',
338
+ '',
339
+ 'List your goals in their display order.',
340
+ '',
341
+ 'Usage:',
342
+ ' sloth-agent goals [list] [--base-url URL]',
343
+ '',
344
+ 'Options:',
345
+ ' --base-url URL Optional. Override the API origin.',
346
+ ' -h, --help Show this help.',
347
+ ...API_ORIGIN_HELP_LINES,
348
+ '',
349
+ 'Constraints:',
350
+ ' No filters or singular get are supported.',
351
+ ' This command is read-only.',
352
+ '',
353
+ 'Output:',
354
+ ' JSON containing currency and goals. Each goal contains id, name,',
355
+ ' targetAmount, targetMonthKey, isAchieved, and sharedWithPartner.',
356
+ ].join('\n');
357
+ }
358
+ export function goalsCreateHelpText() {
359
+ return [
360
+ 'Sloth Agent CLI — goals create',
361
+ '',
362
+ 'Preview or create a goal.',
363
+ '',
364
+ 'Usage:',
365
+ ' sloth-agent goals create --name NAME [options]',
366
+ '',
367
+ 'Options:',
368
+ ' --name NAME Required. Goal name, 1 to 200 characters.',
369
+ ' --target-amount AMOUNT Optional. Positive major-unit amount with up to 2 decimals.',
370
+ ' --target-month YYYY-MM Optional. Target calendar month.',
371
+ ' --apply Optional. Create the goal in Sloth Money.',
372
+ ' --base-url URL Optional. Override the API origin.',
373
+ ' -h, --help Show this help.',
374
+ ...API_ORIGIN_HELP_LINES,
375
+ '',
376
+ 'Safety:',
377
+ ' Without --apply, the command returns a dry-run preview and does not write.',
378
+ ' New goals are private to the owner and appended to the existing goal order.',
379
+ '',
380
+ 'Example:',
381
+ ' sloth-agent goals create --name "Emergency fund" --target-amount 12000',
382
+ ' sloth-agent goals create --name "Emergency fund" --target-amount 12000 --apply',
383
+ '',
384
+ 'Output:',
385
+ ' Preview mode returns dryRun, method, endpoint, and payload.',
386
+ ' Apply mode returns the persisted goal and currency from the 201 response.',
387
+ ].join('\n');
388
+ }
389
+ export function goalsUpdateHelpText() {
390
+ return [
391
+ 'Sloth Agent CLI — goals update',
392
+ '',
393
+ 'Preview or partially update a goal.',
394
+ '',
395
+ 'Usage:',
396
+ ' sloth-agent goals update --goal-id ID [fields] [--apply] [--base-url URL]',
397
+ '',
398
+ 'Options:',
399
+ ' --goal-id ID Required. Goal ID from goals list or create output.',
400
+ ' --name NAME Optional. Replacement name, 1 to 200 characters.',
401
+ ' --target-amount AMOUNT Optional. Positive amount with up to 2 decimals.',
402
+ ' --clear-target-amount Optional. Remove the target amount.',
403
+ ' --target-month YYYY-MM Optional. Replace the target month.',
404
+ ' --clear-target-month Optional. Remove the target month.',
405
+ ' --achieved=true|false Optional. Mark the goal achieved or active.',
406
+ ' --apply Optional. Write the partial update.',
407
+ ' --base-url URL Optional. Override the API origin.',
408
+ ' -h, --help Show this help.',
409
+ ...API_ORIGIN_HELP_LINES,
410
+ '',
411
+ 'Constraints:',
412
+ ' Provide at least one field to update.',
413
+ ' Set and clear options for the same field are mutually exclusive.',
414
+ ' Marking a goal achieved removes its forecast assignment.',
415
+ ' Marking it active again does not restore the previous assignment.',
416
+ ' Change active shared pot target amounts in the Sloth Budget app, where',
417
+ ' account balances can be reconciled across goals in priority order.',
418
+ ' Sharing remains app-managed. Updates to an already shared goal remain visible',
419
+ ' to the connected partner.',
420
+ '',
421
+ 'Safety:',
422
+ ' Without --apply, the command returns a dry-run preview and does not write.',
423
+ '',
424
+ 'Output:',
425
+ ' Preview mode returns dryRun, method, endpoint, and payload.',
426
+ ' Apply mode returns the complete persisted goal and currency.',
427
+ ].join('\n');
428
+ }
429
+ export function goalsDeleteHelpText() {
430
+ return [
431
+ 'Sloth Agent CLI — goals delete',
432
+ '',
433
+ 'Preview or permanently delete a goal.',
434
+ '',
435
+ 'Usage:',
436
+ ' sloth-agent goals delete --goal-id ID [--apply] [--base-url URL]',
437
+ '',
438
+ 'Options:',
439
+ ' --goal-id ID Required. Goal ID from goals list or create output.',
440
+ ' --apply Optional. Permanently delete the goal.',
441
+ ' --base-url URL Optional. Override the API origin.',
442
+ ' -h, --help Show this help.',
443
+ ...API_ORIGIN_HELP_LINES,
444
+ '',
445
+ 'Safety:',
446
+ ' Without --apply, the command returns a dry-run preview and does not write.',
447
+ ' Applying deletion also removes the goal from forecast assignments and',
448
+ ' removes its goal drift history. This operation cannot be undone.',
449
+ '',
450
+ 'Output:',
451
+ ' Preview mode returns dryRun, method, and endpoint.',
452
+ ' Apply mode returns deleted and deletedGoalId.',
453
+ ].join('\n');
454
+ }
455
+ export function askPartnerHelpText() {
456
+ return [
457
+ 'Sloth Agent CLI — ask-partner',
458
+ '',
459
+ 'Create a shareable link asking a partner to clarify a transaction.',
460
+ '',
461
+ 'Usage:',
462
+ ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
463
+ '',
464
+ 'Options:',
465
+ ' --transaction-ref REF Required. Stable transactionRef from transaction output.',
466
+ ' --base-url URL Optional. Override the API origin.',
467
+ ' -h, --help Show this help.',
468
+ ...API_ORIGIN_HELP_LINES,
469
+ '',
470
+ 'Write behavior:',
471
+ ' Running this command creates the request immediately. There is no preview mode.',
472
+ '',
473
+ 'Example:',
474
+ ' sloth-agent ask-partner --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE',
475
+ ' The value shown is a placeholder. Copy the exact transactionRef from',
476
+ ' sloth-agent transactions output.',
477
+ '',
478
+ 'Output:',
479
+ ' JSON containing requestId, publicUrl, message, expiresAt, and status.',
480
+ ].join('\n');
481
+ }
482
+ export function commandHelpText(topic) {
483
+ const helpByTopic = {
484
+ auth: authHelpText,
485
+ 'auth-login': authLoginHelpText,
486
+ 'auth-status': authStatusHelpText,
487
+ 'auth-logout': authLogoutHelpText,
488
+ categories: categoriesHelpText,
489
+ transactions: transactionsHelpText,
490
+ assign: assignHelpText,
491
+ 'joint-budget-settings': jointBudgetSettingsHelpText,
492
+ goals: goalsHelpText,
493
+ 'goals-list': goalsListHelpText,
494
+ 'goals-create': goalsCreateHelpText,
495
+ 'goals-update': goalsUpdateHelpText,
496
+ 'goals-delete': goalsDeleteHelpText,
497
+ 'ask-partner': askPartnerHelpText,
498
+ };
499
+ return helpByTopic[topic]();
500
+ }
26
501
  function writeJson(write, data) {
27
502
  write(`${JSON.stringify(data, null, 2)}\n`);
28
503
  }
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
504
  function redact(value, token) {
36
505
  return token ? value.split(token).join('[REDACTED]') : value;
37
506
  }
507
+ function environmentToken(environment) {
508
+ const token = environment.SLOTH_AGENT_TOKEN;
509
+ return token?.trim() ? token : undefined;
510
+ }
511
+ async function defaultReadSecret() {
512
+ const { default: password } = await import('@inquirer/password');
513
+ return password({
514
+ message: 'Personal access token:',
515
+ mask: '*',
516
+ }, {
517
+ output: process.stderr,
518
+ });
519
+ }
520
+ async function defaultReadStdin() {
521
+ const chunks = [];
522
+ for await (const chunk of process.stdin) {
523
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
524
+ }
525
+ return Buffer.concat(chunks).toString('utf8');
526
+ }
527
+ function validateLoginToken(value) {
528
+ if (!value.startsWith('sloth_pat_v1_') || /\s/.test(value)) {
529
+ throw new UsageError('Personal access token must use the sloth_pat_v1_ prefix and contain no whitespace');
530
+ }
531
+ return value;
532
+ }
533
+ function stripStdinLineEnding(value) {
534
+ if (value.endsWith('\r\n'))
535
+ return value.slice(0, -2);
536
+ if (value.endsWith('\n'))
537
+ return value.slice(0, -1);
538
+ return value;
539
+ }
540
+ async function loadCredentialStore(getCredentialStore) {
541
+ try {
542
+ return await getCredentialStore();
543
+ }
544
+ catch (error) {
545
+ if (error instanceof CliError)
546
+ throw error;
547
+ throw secureStorageUnavailableError();
548
+ }
549
+ }
550
+ async function resolveCredential(environment, origin, getCredentialStore) {
551
+ const token = environmentToken(environment);
552
+ if (token)
553
+ return { source: 'environment', token };
554
+ const credentialStore = await loadCredentialStore(getCredentialStore);
555
+ const storedToken = await credentialStore.get(origin);
556
+ if (!storedToken) {
557
+ throw new ConfigError(`No credential found for ${origin}. Run "sloth-agent auth login" or set SLOTH_AGENT_TOKEN.`);
558
+ }
559
+ return { source: 'keychain', token: storedToken };
560
+ }
38
561
  function readAssignmentFile(filePath) {
39
562
  try {
40
563
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -61,6 +584,9 @@ function buildTransactionsQuery(filters) {
61
584
  params.set('accountId', filters.accountId);
62
585
  if (filters.categoryId !== undefined)
63
586
  params.set('categoryId', filters.categoryId);
587
+ if (filters.assignmentScope !== undefined) {
588
+ params.set('assignmentScope', filters.assignmentScope);
589
+ }
64
590
  if (filters.cursor !== undefined)
65
591
  params.set('cursor', filters.cursor);
66
592
  return params.toString();
@@ -74,7 +600,7 @@ async function parseHttpResponse(response, token) {
74
600
  }
75
601
  catch {
76
602
  if (response.ok)
77
- throw new ApiError('Agent API returned invalid JSON');
603
+ throw new ApiError('Agent API returned invalid JSON', response.status);
78
604
  }
79
605
  }
80
606
  if (!response.ok) {
@@ -84,10 +610,39 @@ async function parseHttpResponse(response, token) {
84
610
  && typeof data.error === 'string')
85
611
  ? data.error
86
612
  : `Agent API request failed with status ${response.status}`;
87
- throw new ApiError(redact(message, token));
613
+ throw new ApiError(redact(message, token), response.status);
88
614
  }
89
615
  return data;
90
616
  }
617
+ function requestHeaders(token) {
618
+ return {
619
+ Accept: 'application/json',
620
+ Authorization: `Bearer ${token}`,
621
+ 'User-Agent': `sloth-agent/${CLI_VERSION}`,
622
+ };
623
+ }
624
+ async function validateCredentialRemotely(fetchImplementation, origin, token) {
625
+ const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
626
+ method: 'GET',
627
+ headers: requestHeaders(token),
628
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
629
+ });
630
+ parseApiResponse('categories', await parseHttpResponse(response, token));
631
+ }
632
+ function maskedTokenSuffix(token) {
633
+ return token.length > 4 ? `…${token.slice(-4)}` : '…';
634
+ }
635
+ function classifyRemoteStatus(error) {
636
+ if (error instanceof ApiError) {
637
+ if (error.status === 401)
638
+ return 'invalid_or_expired';
639
+ if (error.status === 402)
640
+ return 'payment_required';
641
+ if (error.status === 403)
642
+ return 'insufficient_scope';
643
+ }
644
+ return 'unreachable';
645
+ }
91
646
  function hasFailures(value) {
92
647
  if (!value || typeof value !== 'object' || !('failed' in value))
93
648
  return false;
@@ -96,26 +651,178 @@ function hasFailures(value) {
96
651
  export async function runCli(argv = process.argv.slice(2), options = {}) {
97
652
  const environment = options.env ?? process.env;
98
653
  const fetchImplementation = options.fetch ?? globalThis.fetch;
654
+ const getCredentialStore = options.getCredentialStore ?? createSystemCredentialStore;
655
+ const isInteractive = options.isInteractive
656
+ ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
657
+ const readSecret = options.readSecret ?? defaultReadSecret;
658
+ const readStdin = options.readStdin ?? defaultReadStdin;
99
659
  const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
100
660
  const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
101
661
  let token;
102
662
  try {
103
663
  const parsed = parseArgs(argv);
104
664
  if (parsed.command === 'help') {
105
- writeStdout(`${usageText()}\n`);
665
+ writeStdout(`${parsed.topic ? commandHelpText(parsed.topic) : usageText()}\n`);
106
666
  return 0;
107
667
  }
108
668
  if (parsed.command === 'version') {
109
669
  writeStdout(`${CLI_VERSION}\n`);
110
670
  return 0;
111
671
  }
112
- token = requireToken(environment);
113
672
  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
- };
673
+ if (parsed.command === 'auth-login') {
674
+ if (parsed.input === 'prompt' && !isInteractive) {
675
+ throw new UsageError('Interactive login requires a TTY. Use --token-stdin or --from-env.');
676
+ }
677
+ let rawToken;
678
+ if (parsed.input === 'environment') {
679
+ const tokenFromEnvironment = environmentToken(environment);
680
+ if (!tokenFromEnvironment) {
681
+ throw new ConfigError('SLOTH_AGENT_TOKEN is required with --from-env');
682
+ }
683
+ rawToken = tokenFromEnvironment;
684
+ }
685
+ else {
686
+ rawToken = await (parsed.input === 'stdin' ? readStdin() : readSecret());
687
+ if (parsed.input === 'stdin')
688
+ rawToken = stripStdinLineEnding(rawToken);
689
+ }
690
+ token = validateLoginToken(rawToken);
691
+ await validateCredentialRemotely(fetchImplementation, baseUrl, token);
692
+ const credentialStore = await loadCredentialStore(getCredentialStore);
693
+ await credentialStore.set(baseUrl, token);
694
+ const environmentOverrideActive = environmentToken(environment) !== undefined;
695
+ writeJson(writeStdout, {
696
+ activeSource: environmentOverrideActive ? 'environment' : 'keychain',
697
+ environmentOverrideActive,
698
+ origin: baseUrl,
699
+ stored: true,
700
+ });
701
+ return 0;
702
+ }
703
+ if (parsed.command === 'auth-status') {
704
+ const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
705
+ token = credential.token;
706
+ let remoteStatus = 'valid';
707
+ let exitCode = 0;
708
+ try {
709
+ await validateCredentialRemotely(fetchImplementation, baseUrl, token);
710
+ }
711
+ catch (error) {
712
+ remoteStatus = classifyRemoteStatus(error);
713
+ exitCode = 1;
714
+ }
715
+ writeJson(writeStdout, {
716
+ origin: baseUrl,
717
+ remoteStatus,
718
+ source: credential.source,
719
+ tokenSuffix: maskedTokenSuffix(token),
720
+ });
721
+ return exitCode;
722
+ }
723
+ if (parsed.command === 'auth-logout') {
724
+ const credentialStore = await loadCredentialStore(getCredentialStore);
725
+ const localCredentialRemoved = await credentialStore.delete(baseUrl);
726
+ writeJson(writeStdout, {
727
+ environmentOverrideActive: environmentToken(environment) !== undefined,
728
+ localCredentialRemoved,
729
+ origin: baseUrl,
730
+ remoteRevoked: false,
731
+ revocationInstructions: 'Revoke the token in Sloth Money Settings > Developer access.',
732
+ });
733
+ return 0;
734
+ }
735
+ const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
736
+ token = credential.token;
737
+ const headers = requestHeaders(token);
738
+ if (parsed.command === 'goals-create') {
739
+ const endpoint = `${baseUrl}/api/agent/v1/goals`;
740
+ const payload = {
741
+ name: parsed.name,
742
+ ...(parsed.targetAmount === undefined
743
+ ? {}
744
+ : { targetAmount: parsed.targetAmount }),
745
+ ...(parsed.targetMonthKey === undefined
746
+ ? {}
747
+ : { targetMonthKey: parsed.targetMonthKey }),
748
+ };
749
+ if (!parsed.apply) {
750
+ writeJson(writeStdout, {
751
+ dryRun: true,
752
+ endpoint,
753
+ method: 'POST',
754
+ payload,
755
+ });
756
+ return 0;
757
+ }
758
+ const response = await fetchImplementation(endpoint, {
759
+ method: 'POST',
760
+ headers: {
761
+ ...headers,
762
+ 'Content-Type': 'application/json',
763
+ },
764
+ body: JSON.stringify(payload),
765
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
766
+ });
767
+ const data = parseApiResponse('goals-create', await parseHttpResponse(response, token));
768
+ writeJson(writeStdout, data);
769
+ return 0;
770
+ }
771
+ if (parsed.command === 'goals-update') {
772
+ const endpoint = `${baseUrl}/api/agent/v1/goals/${encodeURIComponent(parsed.goalId)}`;
773
+ const payload = {
774
+ ...(parsed.name === undefined ? {} : { name: parsed.name }),
775
+ ...(parsed.targetAmount === undefined
776
+ ? {}
777
+ : { targetAmount: parsed.targetAmount }),
778
+ ...(parsed.targetMonthKey === undefined
779
+ ? {}
780
+ : { targetMonthKey: parsed.targetMonthKey }),
781
+ ...(parsed.isAchieved === undefined
782
+ ? {}
783
+ : { isAchieved: parsed.isAchieved }),
784
+ };
785
+ if (!parsed.apply) {
786
+ writeJson(writeStdout, {
787
+ dryRun: true,
788
+ endpoint,
789
+ method: 'PATCH',
790
+ payload,
791
+ });
792
+ return 0;
793
+ }
794
+ const response = await fetchImplementation(endpoint, {
795
+ method: 'PATCH',
796
+ headers: {
797
+ ...headers,
798
+ 'Content-Type': 'application/json',
799
+ },
800
+ body: JSON.stringify(payload),
801
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
802
+ });
803
+ const data = parseApiResponse('goals-update', await parseHttpResponse(response, token));
804
+ writeJson(writeStdout, data);
805
+ return 0;
806
+ }
807
+ if (parsed.command === 'goals-delete') {
808
+ const endpoint = `${baseUrl}/api/agent/v1/goals/${encodeURIComponent(parsed.goalId)}`;
809
+ if (!parsed.apply) {
810
+ writeJson(writeStdout, {
811
+ dryRun: true,
812
+ endpoint,
813
+ method: 'DELETE',
814
+ });
815
+ return 0;
816
+ }
817
+ const response = await fetchImplementation(endpoint, {
818
+ method: 'DELETE',
819
+ headers,
820
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
821
+ });
822
+ const data = parseApiResponse('goals-delete', await parseHttpResponse(response, token));
823
+ writeJson(writeStdout, data);
824
+ return 0;
825
+ }
119
826
  if (parsed.command === 'assign') {
120
827
  const payload = validateAssignmentPayload(readAssignmentFile(parsed.input));
121
828
  const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
@@ -136,6 +843,36 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
136
843
  writeJson(writeStdout, data);
137
844
  return hasFailures(data) ? 1 : 0;
138
845
  }
846
+ if (parsed.command === 'joint-budget-settings') {
847
+ const endpoint = `${baseUrl}/api/agent/v1/joint-budget-settings`;
848
+ if (parsed.includeSharedPersonalTransactions !== undefined && !parsed.apply) {
849
+ writeJson(writeStdout, {
850
+ dryRun: true,
851
+ endpoint,
852
+ payload: {
853
+ includeSharedPersonalTransactions: parsed.includeSharedPersonalTransactions,
854
+ },
855
+ });
856
+ return 0;
857
+ }
858
+ const response = await fetchImplementation(endpoint, {
859
+ method: parsed.apply ? 'PUT' : 'GET',
860
+ headers: parsed.apply
861
+ ? { ...headers, 'Content-Type': 'application/json' }
862
+ : headers,
863
+ ...(parsed.apply
864
+ ? {
865
+ body: JSON.stringify({
866
+ includeSharedPersonalTransactions: parsed.includeSharedPersonalTransactions,
867
+ }),
868
+ }
869
+ : {}),
870
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
871
+ });
872
+ const data = validateJointBudgetSettingsResponse(await parseHttpResponse(response, token));
873
+ writeJson(writeStdout, data);
874
+ return 0;
875
+ }
139
876
  if (parsed.command === 'ask-partner') {
140
877
  const response = await fetchImplementation(`${baseUrl}/api/agent/v1/transaction-explanation-requests`, {
141
878
  method: 'POST',
@@ -150,6 +887,16 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
150
887
  writeJson(writeStdout, data);
151
888
  return 0;
152
889
  }
890
+ if (parsed.command === 'goals-list') {
891
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/goals`, {
892
+ method: 'GET',
893
+ headers,
894
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
895
+ });
896
+ const data = parseApiResponse('goals-list', await parseHttpResponse(response, token));
897
+ writeJson(writeStdout, data);
898
+ return 0;
899
+ }
153
900
  const path = parsed.command === 'categories'
154
901
  ? '/api/agent/v1/categories'
155
902
  : `/api/agent/v1/transactions${(() => {