@slothmoney/agent-cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.10.0 - 2026-08-15
4
+
5
+ - Require every goal create to specify a positive target amount and Keep or
6
+ Spend type, matching the breaking Agent Goals API v1 contract.
7
+ - Expose `goalType` and nullable `spentAt`, allow active goal type changes, and
8
+ remove obsolete `isAchieved` and target-amount clearing assumptions.
9
+ - Add preview-by-default `goals mark-spent` and `goals restore` actions that
10
+ send the canonical `isSpent` lifecycle update only with `--apply`.
11
+
12
+ ## 0.9.1 - 2026-08-15
13
+
14
+ - Clarify how personal and joint category assignments appear in transaction
15
+ results and how assignment scope affects uncategorised filters.
16
+ - Document complete category and line-item assignments, including the
17
+ category-specific `Other` fallback when no more specific line item fits.
18
+ - Extend command-help and clean-installed package checks for the categorisation
19
+ workflow.
4
20
 
5
21
  ## 0.9.0 - 2026-08-12
6
22
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ sloth-agent --version
15
15
  For a one-off pinned run:
16
16
 
17
17
  ```bash
18
- npm exec --yes --package=@slothmoney/agent-cli@0.9.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.10.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -112,9 +112,39 @@ sloth-agent transactions --help
112
112
  sloth-agent assign --help
113
113
  sloth-agent goals create --help
114
114
  sloth-agent goals update --help
115
+ sloth-agent goals mark-spent --help
116
+ sloth-agent goals restore --help
115
117
  sloth-agent ask-partner --help
116
118
  ```
117
119
 
120
+ ### How transaction categorisation is represented
121
+
122
+ Personal and joint category assignments are separate. A personal assignment
123
+ uses the transaction's top-level `categoryId`, `lineItemId`, and
124
+ `categorySplits`. A joint-budget assignment uses the corresponding fields under
125
+ `jointBudgetContribution`.
126
+
127
+ A transaction can have no personal category while its joint-budget contribution
128
+ already has a category and line item. To assess a transaction's categorisation,
129
+ inspect both locations. An included joint contribution with a category and line
130
+ item is already categorised for the joint budget. For example:
131
+
132
+ ```json
133
+ {
134
+ "categoryId": null,
135
+ "lineItemId": null,
136
+ "jointBudgetContribution": {
137
+ "included": true,
138
+ "categoryId": "groceries",
139
+ "lineItemId": "joint-groceries"
140
+ }
141
+ }
142
+ ```
143
+
144
+ This transaction is uncategorised personally but categorised as Groceries for
145
+ the joint budget. The `--uncategorized` filter applies to the selected
146
+ assignment scope; personal is used when `--assignment-scope` is omitted.
147
+
118
148
  ### Categorise a transaction end to end
119
149
 
120
150
  1. Read categories and available budget line items:
@@ -127,23 +157,43 @@ A category is the broader parent. A line item is a child within one category.
127
157
  Line-item names such as `Other` may repeat, so preserve the full choice as
128
158
  `(scope, categoryId, lineItemId)`. Use the personal or joint line-item map that
129
159
  matches the transaction scope. For example, `Bills → Other` and `Subscriptions
130
- → Other` are different choices.
160
+ → Other` are different choices. Choose the most specific suitable line item;
161
+ if none fits, use that category's `Other` line item. Historical assignments
162
+ without a line item should not be treated as a recommendation to omit one.
131
163
 
132
164
  2. Read uncategorised transactions:
133
165
 
134
166
  ```bash
135
- sloth-agent transactions --uncategorized --limit 50
167
+ sloth-agent transactions \
168
+ --assignment-scope personal \
169
+ --uncategorized \
170
+ --limit 50
136
171
  ```
137
172
 
138
- 3. Copy the exact `transactionRef` for the transaction and a `categoryId` from
139
- the earlier outputs into `assignments.json`:
173
+ To read uncategorised joint-budget contributions instead:
174
+
175
+ ```bash
176
+ sloth-agent transactions \
177
+ --assignment-scope joint \
178
+ --uncategorized \
179
+ --limit 50
180
+ ```
181
+
182
+ The remaining example continues with a personal assignment. For a joint
183
+ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
184
+ `--assignment-scope joint` when checking the result.
185
+
186
+ 3. Copy the exact `transactionRef`, `categoryId`, and `lineItemId` from the
187
+ earlier outputs into `assignments.json`:
140
188
 
141
189
  ```json
142
190
  {
143
191
  "assignments": [
144
192
  {
145
193
  "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",
146
- "categoryId": "PASTE_A_CATEGORY_ID_HERE"
194
+ "assignmentScope": "personal",
195
+ "categoryId": "PASTE_A_CATEGORY_ID_HERE",
196
+ "lineItemId": "PASTE_A_LINE_ITEM_ID_HERE"
147
197
  }
148
198
  ]
149
199
  }
@@ -172,17 +222,19 @@ This step requires a token created with **Allow changes**.
172
222
 
173
223
  Inspect every item in the returned `succeeded` and `failed` arrays.
174
224
 
175
- 6. Check the result. Successful assignments update the category and optional
176
- budget line item on the original transaction. See the result in **Sloth
177
- Money Transactions**, or re-run the original transaction query without
178
- `--uncategorized` and inspect its category fields:
225
+ 6. Check the result in the same assignment scope that you changed. Successful
226
+ assignments update the category and optional budget line item on the
227
+ original transaction. See the result in **Sloth Money Transactions**, or
228
+ re-run the original transaction query without `--uncategorized` and inspect
229
+ both the personal and joint category fields:
179
230
 
180
231
  ```bash
181
- sloth-agent transactions --limit 50
232
+ sloth-agent transactions --assignment-scope personal --limit 50
182
233
  ```
183
234
 
184
235
  The transaction should also disappear from the matching `--uncategorized`
185
- query. Assignments do not create a separate list.
236
+ query. Confirm that an existing assignment in the other scope was not changed.
237
+ Assignments do not create a separate list.
186
238
 
187
239
  ### Other workflows
188
240
 
@@ -363,45 +415,64 @@ Goal writes are previews unless `--apply` is present:
363
415
  sloth-agent goals create \
364
416
  --name "Emergency fund" \
365
417
  --target-amount 12000 \
366
- --target-month 2027-06
418
+ --type keep
367
419
 
368
420
  sloth-agent goals create \
369
- --name "Emergency fund" \
370
- --target-amount 12000 \
421
+ --name "Wedding" \
422
+ --target-amount 22000 \
371
423
  --target-month 2027-06 \
424
+ --type spend \
372
425
  --apply
373
426
  ```
374
427
 
375
- Use the `id` from list or create output to update or delete goals:
428
+ Every goal is either Keep or Spend. A Keep goal continues reserving its funded
429
+ money. A Spend goal reserves money until you explicitly mark it spent. Goal
430
+ list, create, and update output includes lowercase `goalType` and nullable
431
+ `spentAt`; `spentAt` is an ISO timestamp only after a Spend goal is marked
432
+ spent.
433
+
434
+ Use the `id` from list or create output to update a goal, change its type, or
435
+ move it in the priority order:
376
436
 
377
437
  ```bash
378
438
  sloth-agent goals update \
379
439
  --goal-id goal-id \
380
- --clear-target-amount \
381
440
  --target-month 2027-12 \
382
- --achieved=false \
441
+ --type spend \
383
442
  --apply
384
443
 
385
444
  sloth-agent goals update \
386
445
  --goal-id house-goal-id \
387
446
  --priority 2 \
388
447
  --apply
448
+ ```
449
+
450
+ Marking spent and restoring are also previews by default:
451
+
452
+ ```bash
453
+ sloth-agent goals mark-spent --goal-id goal-id
454
+ sloth-agent goals mark-spent --goal-id goal-id --apply
455
+
456
+ sloth-agent goals restore --goal-id goal-id
457
+ sloth-agent goals restore --goal-id goal-id --apply
389
458
 
390
459
  sloth-agent goals delete --goal-id goal-id --apply
391
460
  ```
392
461
 
393
- Updates are partial. Use `--clear-target-amount` or `--clear-target-month` to
394
- remove an optional value. Marking a goal achieved removes its forecast
395
- assignment. Deleting a goal also removes its forecast assignments and drift
396
- history. Goal sharing remains app-managed. Change an active shared goal's
397
- pot-tracked target amount in the Sloth Budget app, where account balances can
398
- be reallocated across goals in priority order. Goal list output includes a
462
+ Updates are partial. Use `--clear-target-month` to remove the optional month.
463
+ A Keep goal cannot be marked spent. A spent goal must be restored before its
464
+ type can change; the API returns these lifecycle conflicts without hiding the
465
+ required recovery action. Restoring clears `spentAt` and returns the goal to
466
+ allocation at its saved priority. Deleting a goal also removes its forecast
467
+ assignments and drift history.
468
+
469
+ Goal sharing remains app-managed. Change an active shared goal's pot-tracked
470
+ target amount in the Sloth Budget app, where account balances can be
471
+ reallocated across goals in priority order. Goal list output includes a
399
472
  one-based `priority`; `1` is highest. Moving one goal automatically shifts the
400
- goals between its old and new positions. The
401
- priority option must be used on its own, and the write persists immediately.
402
- Forecast assignments and shared pot
403
- progress are browser-owned derived state and refresh when the owner next opens
404
- the Forecast screen.
473
+ goals between its old and new positions. The priority option must be used on
474
+ its own. Forecast assignments and shared pot progress are browser-owned
475
+ derived state and refresh when the owner next opens the Forecast screen.
405
476
 
406
477
  Read uncategorised contributions to the joint budget:
407
478
 
package/dist/args.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { UsageError } from './errors.js';
2
2
  import { CATEGORY_TYPES, ICON_KEYS, } from './category-metadata.js';
3
+ import { isGoalType, } from './goal-metadata.js';
3
4
  const PRODUCTION_BASE_URL = 'https://budget.slothmoney.app';
4
5
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
5
6
  function readOptionValue(args, index, name) {
@@ -74,11 +75,11 @@ function parseGoalPriority(value) {
74
75
  }
75
76
  return priority;
76
77
  }
77
- function parseExplicitBoolean(value, name) {
78
- if (value !== 'true' && value !== 'false') {
79
- throw new UsageError(`${name} must be true or false`);
78
+ function parseGoalType(value) {
79
+ if (!isGoalType(value)) {
80
+ throw new UsageError('--type must be keep or spend');
80
81
  }
81
- return value === 'true';
82
+ return value;
82
83
  }
83
84
  function parseGoalName(value) {
84
85
  const name = value.trim();
@@ -500,6 +501,7 @@ function parseGoals(args, baseUrl) {
500
501
  let name;
501
502
  let targetAmount;
502
503
  let targetMonthKey;
504
+ let goalType;
503
505
  let apply = false;
504
506
  for (let index = 0; index < args.length; index += 1) {
505
507
  const argument = args[index];
@@ -514,7 +516,8 @@ function parseGoals(args, baseUrl) {
514
516
  : [argument, undefined];
515
517
  if (option !== '--name'
516
518
  && option !== '--target-amount'
517
- && option !== '--target-month') {
519
+ && option !== '--target-month'
520
+ && option !== '--type') {
518
521
  throw new UsageError(`Unknown goals create option: ${argument}`);
519
522
  }
520
523
  const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
@@ -526,17 +529,27 @@ function parseGoals(args, baseUrl) {
526
529
  else if (option === '--target-amount') {
527
530
  targetAmount = setOnce(targetAmount, parseGoalAmount(value, option), option);
528
531
  }
529
- else {
532
+ else if (option === '--target-month') {
530
533
  targetMonthKey = setOnce(targetMonthKey, parseGoalMonthKey(value, option), option);
531
534
  }
535
+ else {
536
+ goalType = setOnce(goalType, parseGoalType(value), option);
537
+ }
532
538
  }
533
539
  if (!name)
534
540
  throw new UsageError('goals create requires --name <name>');
541
+ if (targetAmount === undefined) {
542
+ throw new UsageError('goals create requires --target-amount <amount>');
543
+ }
544
+ if (goalType === undefined) {
545
+ throw new UsageError('goals create requires --type <keep|spend>');
546
+ }
535
547
  return withBaseUrl({
536
548
  command: 'goals-create',
537
549
  name,
538
- ...(targetAmount === undefined ? {} : { targetAmount }),
550
+ targetAmount,
539
551
  ...(targetMonthKey === undefined ? {} : { targetMonthKey }),
552
+ goalType,
540
553
  apply,
541
554
  }, baseUrl);
542
555
  }
@@ -545,7 +558,7 @@ function parseGoals(args, baseUrl) {
545
558
  let name;
546
559
  let targetAmount;
547
560
  let targetMonthKey;
548
- let isAchieved;
561
+ let goalType;
549
562
  let priority;
550
563
  let apply = false;
551
564
  for (let index = 0; index < args.length; index += 1) {
@@ -556,13 +569,6 @@ function parseGoals(args, baseUrl) {
556
569
  apply = true;
557
570
  continue;
558
571
  }
559
- if (argument === '--clear-target-amount') {
560
- if (targetAmount !== undefined) {
561
- throw new UsageError('--target-amount and --clear-target-amount are mutually exclusive');
562
- }
563
- targetAmount = null;
564
- continue;
565
- }
566
572
  if (argument === '--clear-target-month') {
567
573
  if (targetMonthKey !== undefined) {
568
574
  throw new UsageError('--target-month and --clear-target-month are mutually exclusive');
@@ -577,7 +583,7 @@ function parseGoals(args, baseUrl) {
577
583
  && option !== '--name'
578
584
  && option !== '--target-amount'
579
585
  && option !== '--target-month'
580
- && option !== '--achieved'
586
+ && option !== '--type'
581
587
  && option !== '--priority') {
582
588
  throw new UsageError(`Unknown goals update option: ${argument}`);
583
589
  }
@@ -591,10 +597,7 @@ function parseGoals(args, baseUrl) {
591
597
  name = setOnce(name, parseGoalName(value), option);
592
598
  }
593
599
  else if (option === '--target-amount') {
594
- if (targetAmount !== undefined) {
595
- throw new UsageError('--target-amount and --clear-target-amount are mutually exclusive');
596
- }
597
- targetAmount = parseGoalAmount(value, option);
600
+ targetAmount = setOnce(targetAmount, parseGoalAmount(value, option), option);
598
601
  }
599
602
  else if (option === '--target-month') {
600
603
  if (targetMonthKey !== undefined) {
@@ -606,7 +609,7 @@ function parseGoals(args, baseUrl) {
606
609
  priority = setOnce(priority, parseGoalPriority(value), option);
607
610
  }
608
611
  else {
609
- isAchieved = setOnce(isAchieved, parseExplicitBoolean(value, option), option);
612
+ goalType = setOnce(goalType, parseGoalType(value), option);
610
613
  }
611
614
  }
612
615
  if (!goalId)
@@ -614,7 +617,7 @@ function parseGoals(args, baseUrl) {
614
617
  if (name === undefined
615
618
  && targetAmount === undefined
616
619
  && targetMonthKey === undefined
617
- && isAchieved === undefined
620
+ && goalType === undefined
618
621
  && priority === undefined) {
619
622
  throw new UsageError('goals update requires at least one field to update');
620
623
  }
@@ -622,7 +625,7 @@ function parseGoals(args, baseUrl) {
622
625
  && (name !== undefined
623
626
  || targetAmount !== undefined
624
627
  || targetMonthKey !== undefined
625
- || isAchieved !== undefined)) {
628
+ || goalType !== undefined)) {
626
629
  throw new UsageError('--priority must be used on its own');
627
630
  }
628
631
  return withBaseUrl({
@@ -631,12 +634,14 @@ function parseGoals(args, baseUrl) {
631
634
  ...(name === undefined ? {} : { name }),
632
635
  ...(targetAmount === undefined ? {} : { targetAmount }),
633
636
  ...(targetMonthKey === undefined ? {} : { targetMonthKey }),
634
- ...(isAchieved === undefined ? {} : { isAchieved }),
637
+ ...(goalType === undefined ? {} : { goalType }),
635
638
  ...(priority === undefined ? {} : { priority }),
636
639
  apply,
637
640
  }, baseUrl);
638
641
  }
639
- if (subcommand === 'delete') {
642
+ if (subcommand === 'mark-spent'
643
+ || subcommand === 'restore'
644
+ || subcommand === 'delete') {
640
645
  let goalId;
641
646
  let apply = false;
642
647
  for (let index = 0; index < args.length; index += 1) {
@@ -651,17 +656,22 @@ function parseGoals(args, baseUrl) {
651
656
  ? argument.split(/=(.*)/s, 2)
652
657
  : [argument, undefined];
653
658
  if (option !== '--goal-id') {
654
- throw new UsageError(`Unknown goals delete option: ${argument}`);
659
+ throw new UsageError(`Unknown goals ${subcommand} option: ${argument}`);
655
660
  }
656
661
  const value = requireNonEmpty(inlineValue ?? readOptionValue(args, index, option), option);
657
662
  if (inlineValue === undefined)
658
663
  index += 1;
659
664
  goalId = setOnce(goalId, parseGoalId(value), option);
660
665
  }
661
- if (!goalId)
662
- throw new UsageError('goals delete requires --goal-id <id>');
666
+ if (!goalId) {
667
+ throw new UsageError(`goals ${subcommand} requires --goal-id <id>`);
668
+ }
663
669
  return withBaseUrl({
664
- command: 'goals-delete',
670
+ command: subcommand === 'mark-spent'
671
+ ? 'goals-mark-spent'
672
+ : subcommand === 'restore'
673
+ ? 'goals-restore'
674
+ : 'goals-delete',
665
675
  goalId,
666
676
  apply,
667
677
  }, baseUrl);
@@ -699,6 +709,10 @@ function helpTopic(argv) {
699
709
  return 'goals-create';
700
710
  if (subcommand === 'update')
701
711
  return 'goals-update';
712
+ if (subcommand === 'mark-spent')
713
+ return 'goals-mark-spent';
714
+ if (subcommand === 'restore')
715
+ return 'goals-restore';
702
716
  if (subcommand === 'delete')
703
717
  return 'goals-delete';
704
718
  return 'goals';
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { ICON_KEYS } from './category-metadata.js';
4
4
  import { parseApiResponse, validateAssignmentPayload, validateBudgetUpdatePayload, } from './contracts.js';
5
5
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
6
6
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
7
- export const CLI_VERSION = '0.9.0';
7
+ export const CLI_VERSION = '0.10.0';
8
8
  const REQUEST_TIMEOUT_MS = 60_000;
9
9
  const API_ORIGIN_HELP_LINES = [
10
10
  '',
@@ -43,9 +43,11 @@ export function usageText() {
43
43
  ' [--cursor CURSOR] [--base-url URL]',
44
44
  ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
45
45
  ' sloth-agent goals [list] [--base-url URL]',
46
- ' sloth-agent goals create --name NAME [--target-amount AMOUNT]',
47
- ' [--target-month YYYY-MM] [--apply] [--base-url URL]',
46
+ ' sloth-agent goals create --name NAME --target-amount AMOUNT',
47
+ ' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
48
48
  ' sloth-agent goals update --goal-id ID [fields] [--apply] [--base-url URL]',
49
+ ' sloth-agent goals mark-spent --goal-id ID [--apply] [--base-url URL]',
50
+ ' sloth-agent goals restore --goal-id ID [--apply] [--base-url URL]',
49
51
  ' sloth-agent goals delete --goal-id ID [--apply] [--base-url URL]',
50
52
  ' sloth-agent ask-partner --transaction-ref REF [--base-url URL]',
51
53
  '',
@@ -185,6 +187,8 @@ export function categoriesHelpText() {
185
187
  ' Line-item names such as "Other" may repeat. Preserve the full choice as',
186
188
  ' (scope, categoryId, lineItemId), using the personal or joint line-item',
187
189
  ' map matching the transaction scope.',
190
+ ' Choose the most specific suitable line item. If none fits, use that',
191
+ ' category\'s "Other" line item.',
188
192
  '',
189
193
  'Examples:',
190
194
  ' sloth-agent categories',
@@ -463,7 +467,8 @@ export function transactionsHelpText() {
463
467
  ' sloth-agent transactions [options]',
464
468
  '',
465
469
  'Options:',
466
- ' --uncategorized[=true|false] Optional. Filter by state; with no value, use true.',
470
+ ' --uncategorized[=true|false] Optional. Filter the selected assignment scope by state;',
471
+ ' with no value, use true.',
467
472
  ' --limit N Optional. Integer from 1 to 200; omit for API default.',
468
473
  ' --start-date YYYY-MM-DD Optional. Include transactions on or after this date.',
469
474
  ' --end-date YYYY-MM-DD Optional. Include transactions on or before this date.',
@@ -472,6 +477,7 @@ export function transactionsHelpText() {
472
477
  ' --category-id ID Optional. Filter by category ID.',
473
478
  ' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
474
479
  ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
480
+ ' Personal is used when omitted.',
475
481
  ' --cursor CURSOR Optional. Continue from a previous nextCursor.',
476
482
  ' --base-url URL Optional. Override the API origin.',
477
483
  ' -h, --help Show this help.',
@@ -487,6 +493,10 @@ export function transactionsHelpText() {
487
493
  'Output:',
488
494
  ' JSON containing transactions, nextCursor, and structured refresh status.',
489
495
  ' Refresh failures do not hide readable cached transactions.',
496
+ ' Personal assignments use the top-level categoryId, lineItemId, and categorySplits.',
497
+ ' Joint assignments appear under jointBudgetContribution.',
498
+ ' A transaction can be uncategorised personally while its joint-budget contribution',
499
+ ' is already categorised. To assess its categorisation, inspect both locations.',
490
500
  ' Use nextCursor with --cursor',
491
501
  ' to request the next page. A null nextCursor means there are no more pages.',
492
502
  '',
@@ -529,29 +539,34 @@ export function assignHelpText() {
529
539
  ' categories output. The example values below are placeholders.',
530
540
  ' Set categoryId to null to clear an assignment.',
531
541
  ' lineItemId is optional and accepts a non-empty string or null.',
542
+ ' When the category has line items, choose the most specific suitable line item.',
543
+ ' If none fits, use that category\'s Other line item from the matching scope.',
532
544
  ' categorySplits is optional and accepts a non-empty array or null.',
533
545
  ' Each split requires categoryId and a positive integer amountPence;',
534
546
  ' a split lineItemId is optional.',
535
547
  ' incomeSubtype is optional and accepts "pay", "interest", or null.',
536
548
  ' assignmentScope is optional and accepts "personal" or "joint".',
549
+ ' Personal is used when assignmentScope is omitted.',
537
550
  '',
538
551
  'Workflow:',
539
552
  ' sloth-agent categories',
540
- ' sloth-agent transactions --uncategorized --limit 50',
553
+ ' sloth-agent transactions --assignment-scope personal --uncategorized --limit 50',
541
554
  ' sloth-agent assign --input assignments.json Preview only',
542
555
  ' sloth-agent assign --input assignments.json --apply Write assignments',
543
- ' sloth-agent transactions --limit 50 Read back the result',
556
+ ' sloth-agent transactions --assignment-scope personal --limit 50 Read back',
544
557
  '',
545
558
  'Example:',
546
559
  ' {',
547
560
  ' "assignments": [',
548
561
  ' {',
549
562
  ' "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",',
550
- ' "categoryId": "PASTE_A_CATEGORY_ID_HERE"',
563
+ ' "assignmentScope": "personal",',
564
+ ' "categoryId": "PASTE_A_CATEGORY_ID_HERE",',
565
+ ' "lineItemId": "PASTE_A_LINE_ITEM_ID_HERE"',
551
566
  ' }',
552
567
  ' ]',
553
568
  ' }',
554
- ' These are placeholders. Replace both values with exact IDs from CLI output.',
569
+ ' These are placeholders. Replace all three values with exact IDs from CLI output.',
555
570
  '',
556
571
  'Output:',
557
572
  ' Preview mode returns dryRun, endpoint, and the validated payload.',
@@ -565,12 +580,14 @@ export function goalsHelpText() {
565
580
  return [
566
581
  'Sloth Agent CLI — goals',
567
582
  '',
568
- 'List, create, update, or delete your savings goals.',
583
+ 'List, create, update, mark spent, restore, or delete your savings goals.',
569
584
  '',
570
585
  'Commands:',
571
586
  ' sloth-agent goals list List goals; "sloth-agent goals" is equivalent.',
572
587
  ' sloth-agent goals create Preview or create a goal.',
573
588
  ' sloth-agent goals update Preview or update selected goal fields.',
589
+ ' sloth-agent goals mark-spent Preview or mark a Spend goal spent.',
590
+ ' sloth-agent goals restore Preview or restore a spent goal.',
574
591
  ' sloth-agent goals delete Preview or permanently delete a goal.',
575
592
  '',
576
593
  'Help:',
@@ -598,7 +615,8 @@ export function goalsListHelpText() {
598
615
  '',
599
616
  'Output:',
600
617
  ' JSON containing currency and goals. Each goal contains id, name, priority,',
601
- ' targetAmount, targetMonthKey, isAchieved, and sharedWithPartner.',
618
+ ' targetAmount, targetMonthKey, goalType, nullable spentAt, and',
619
+ ' sharedWithPartner.',
602
620
  ].join('\n');
603
621
  }
604
622
  export function goalsCreateHelpText() {
@@ -608,11 +626,12 @@ export function goalsCreateHelpText() {
608
626
  'Preview or create a goal.',
609
627
  '',
610
628
  'Usage:',
611
- ' sloth-agent goals create --name NAME [options]',
629
+ ' sloth-agent goals create --name NAME --target-amount AMOUNT --type keep|spend [options]',
612
630
  '',
613
631
  'Options:',
614
632
  ' --name NAME Required. Goal name, 1 to 200 characters.',
615
- ' --target-amount AMOUNT Optional. Positive major-unit amount with up to 2 decimals.',
633
+ ' --target-amount AMOUNT Required. Positive major-unit amount with up to 2 decimals.',
634
+ ' --type keep|spend Required. Keep reserves funded money; Spend is spent later.',
616
635
  ' --target-month YYYY-MM Optional. Target calendar month.',
617
636
  ' --apply Optional. Create the goal in Sloth Money.',
618
637
  ' --base-url URL Optional. Override the API origin.',
@@ -625,8 +644,8 @@ export function goalsCreateHelpText() {
625
644
  ' New goals are private to the owner and appended to the existing goal order.',
626
645
  '',
627
646
  'Example:',
628
- ' sloth-agent goals create --name "Emergency fund" --target-amount 12000',
629
- ' sloth-agent goals create --name "Emergency fund" --target-amount 12000 --apply',
647
+ ' sloth-agent goals create --name "Emergency fund" --target-amount 12000 --type keep',
648
+ ' sloth-agent goals create --name "Wedding" --target-amount 22000 --type spend --target-month 2027-06 --apply',
630
649
  '',
631
650
  'Output:',
632
651
  ' Preview mode returns dryRun, method, endpoint, and payload.',
@@ -646,10 +665,9 @@ export function goalsUpdateHelpText() {
646
665
  ' --goal-id ID Required. Goal ID from goals list or create output.',
647
666
  ' --name NAME Optional. Replacement name, 1 to 200 characters.',
648
667
  ' --target-amount AMOUNT Optional. Positive amount with up to 2 decimals.',
649
- ' --clear-target-amount Optional. Remove the target amount.',
650
668
  ' --target-month YYYY-MM Optional. Replace the target month.',
651
669
  ' --clear-target-month Optional. Remove the target month.',
652
- ' --achieved=true|false Optional. Mark the goal achieved or active.',
670
+ ' --type keep|spend Optional. Change how funded money is treated.',
653
671
  ' --priority POSITION Optional. Positive whole-number position; 1 is highest.',
654
672
  ' --apply Optional. Write the partial update.',
655
673
  ' --base-url URL Optional. Override the API origin.',
@@ -663,9 +681,8 @@ export function goalsUpdateHelpText() {
663
681
  ' Moving a goal shifts the intervening goals automatically.',
664
682
  ' Forecast assignments and shared progress refresh when the owner next opens',
665
683
  ' the Forecast screen.',
666
- ' Set and clear options for the same field are mutually exclusive.',
667
- ' Marking a goal achieved removes its forecast assignment.',
668
- ' Marking it active again does not restore the previous assignment.',
684
+ ' Set and clear target-month options are mutually exclusive.',
685
+ ' Restore a spent goal before changing its type.',
669
686
  ' Change active shared pot target amounts in the Sloth Budget app, where',
670
687
  ' account balances can be reconciled across goals in priority order.',
671
688
  ' Sharing remains app-managed. Updates to an already shared goal remain visible',
@@ -676,6 +693,7 @@ export function goalsUpdateHelpText() {
676
693
  ' Applying requires a write-enabled token created with Allow changes.',
677
694
  '',
678
695
  'Example:',
696
+ ' sloth-agent goals update --goal-id wedding --type spend --apply',
679
697
  ' sloth-agent goals update --goal-id goal-3 --priority 2 --apply',
680
698
  '',
681
699
  'Output:',
@@ -683,6 +701,63 @@ export function goalsUpdateHelpText() {
683
701
  ' Apply mode returns the complete persisted goal and currency.',
684
702
  ].join('\n');
685
703
  }
704
+ export function goalsMarkSpentHelpText() {
705
+ return [
706
+ 'Sloth Agent CLI — goals mark-spent',
707
+ '',
708
+ 'Preview or mark a Spend goal spent.',
709
+ '',
710
+ 'Usage:',
711
+ ' sloth-agent goals mark-spent --goal-id ID [--apply] [--base-url URL]',
712
+ '',
713
+ 'Options:',
714
+ ' --goal-id ID Required. Spend goal ID from goals list or create output.',
715
+ ' --apply Optional. Mark the goal spent in Sloth Money.',
716
+ ' --base-url URL Optional. Override the API origin.',
717
+ ' -h, --help Show this help.',
718
+ ...API_ORIGIN_HELP_LINES,
719
+ '',
720
+ 'Safety and lifecycle:',
721
+ ' Without --apply, the command previews PATCH {"isSpent":true} and does not write.',
722
+ ' Keep goals cannot be marked spent. Change an active goal to Spend first.',
723
+ ' A spent goal is excluded from future goal allocation until restored.',
724
+ '',
725
+ 'Example:',
726
+ ' sloth-agent goals mark-spent --goal-id wedding --apply',
727
+ '',
728
+ 'Output:',
729
+ ' Preview mode returns dryRun, method, endpoint, and payload.',
730
+ ' Apply mode returns the complete persisted goal and currency.',
731
+ ].join('\n');
732
+ }
733
+ export function goalsRestoreHelpText() {
734
+ return [
735
+ 'Sloth Agent CLI — goals restore',
736
+ '',
737
+ 'Preview or restore a spent Spend goal.',
738
+ '',
739
+ 'Usage:',
740
+ ' sloth-agent goals restore --goal-id ID [--apply] [--base-url URL]',
741
+ '',
742
+ 'Options:',
743
+ ' --goal-id ID Required. Spent goal ID from goals list output.',
744
+ ' --apply Optional. Restore the goal in Sloth Money.',
745
+ ' --base-url URL Optional. Override the API origin.',
746
+ ' -h, --help Show this help.',
747
+ ...API_ORIGIN_HELP_LINES,
748
+ '',
749
+ 'Safety and lifecycle:',
750
+ ' Without --apply, the command previews PATCH {"isSpent":false} and does not write.',
751
+ ' Restoring clears spentAt and returns the goal to allocation at its saved priority.',
752
+ '',
753
+ 'Example:',
754
+ ' sloth-agent goals restore --goal-id wedding --apply',
755
+ '',
756
+ 'Output:',
757
+ ' Preview mode returns dryRun, method, endpoint, and payload.',
758
+ ' Apply mode returns the complete persisted goal and currency.',
759
+ ].join('\n');
760
+ }
686
761
  export function goalsDeleteHelpText() {
687
762
  return [
688
763
  'Sloth Agent CLI — goals delete',
@@ -761,6 +836,8 @@ export function commandHelpText(topic) {
761
836
  'goals-list': goalsListHelpText,
762
837
  'goals-create': goalsCreateHelpText,
763
838
  'goals-update': goalsUpdateHelpText,
839
+ 'goals-mark-spent': goalsMarkSpentHelpText,
840
+ 'goals-restore': goalsRestoreHelpText,
764
841
  'goals-delete': goalsDeleteHelpText,
765
842
  'ask-partner': askPartnerHelpText,
766
843
  };
@@ -1143,12 +1220,11 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1143
1220
  const endpoint = `${baseUrl}/api/agent/v1/goals`;
1144
1221
  const payload = {
1145
1222
  name: parsed.name,
1146
- ...(parsed.targetAmount === undefined
1147
- ? {}
1148
- : { targetAmount: parsed.targetAmount }),
1223
+ targetAmount: parsed.targetAmount,
1149
1224
  ...(parsed.targetMonthKey === undefined
1150
1225
  ? {}
1151
1226
  : { targetMonthKey: parsed.targetMonthKey }),
1227
+ goalType: parsed.goalType,
1152
1228
  };
1153
1229
  if (!parsed.apply) {
1154
1230
  writeJson(writeStdout, {
@@ -1172,23 +1248,27 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1172
1248
  writeJson(writeStdout, data);
1173
1249
  return 0;
1174
1250
  }
1175
- if (parsed.command === 'goals-update') {
1251
+ if (parsed.command === 'goals-update'
1252
+ || parsed.command === 'goals-mark-spent'
1253
+ || parsed.command === 'goals-restore') {
1176
1254
  const endpoint = `${baseUrl}/api/agent/v1/goals/${encodeURIComponent(parsed.goalId)}`;
1177
- const payload = {
1178
- ...(parsed.name === undefined ? {} : { name: parsed.name }),
1179
- ...(parsed.targetAmount === undefined
1180
- ? {}
1181
- : { targetAmount: parsed.targetAmount }),
1182
- ...(parsed.targetMonthKey === undefined
1183
- ? {}
1184
- : { targetMonthKey: parsed.targetMonthKey }),
1185
- ...(parsed.isAchieved === undefined
1186
- ? {}
1187
- : { isAchieved: parsed.isAchieved }),
1188
- ...(parsed.priority === undefined
1189
- ? {}
1190
- : { priority: parsed.priority }),
1191
- };
1255
+ const payload = parsed.command === 'goals-update'
1256
+ ? {
1257
+ ...(parsed.name === undefined ? {} : { name: parsed.name }),
1258
+ ...(parsed.targetAmount === undefined
1259
+ ? {}
1260
+ : { targetAmount: parsed.targetAmount }),
1261
+ ...(parsed.targetMonthKey === undefined
1262
+ ? {}
1263
+ : { targetMonthKey: parsed.targetMonthKey }),
1264
+ ...(parsed.goalType === undefined
1265
+ ? {}
1266
+ : { goalType: parsed.goalType }),
1267
+ ...(parsed.priority === undefined
1268
+ ? {}
1269
+ : { priority: parsed.priority }),
1270
+ }
1271
+ : { isSpent: parsed.command === 'goals-mark-spent' };
1192
1272
  if (!parsed.apply) {
1193
1273
  writeJson(writeStdout, {
1194
1274
  dryRun: true,
@@ -1207,8 +1287,8 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1207
1287
  body: JSON.stringify(payload),
1208
1288
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1209
1289
  });
1210
- const data = parseApiResponse('goals-update', await parseHttpResponse(response, token));
1211
- writeJson(writeStdout, parsed.priority === undefined
1290
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1291
+ writeJson(writeStdout, parsed.command !== 'goals-update' || parsed.priority === undefined
1212
1292
  ? data
1213
1293
  : withUpdatedGoalPriority(data, parsed.priority));
1214
1294
  return 0;
package/dist/contracts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ApiError, UsageError, } from './errors.js';
2
2
  import { CATEGORY_TYPES, ICON_KEYS } from './category-metadata.js';
3
+ import { isGoalType } from './goal-metadata.js';
3
4
  function isObject(value) {
4
5
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
5
6
  }
@@ -297,7 +298,8 @@ function isGoal(value) {
297
298
  'name',
298
299
  'targetAmount',
299
300
  'targetMonthKey',
300
- 'isAchieved',
301
+ 'goalType',
302
+ 'spentAt',
301
303
  'sharedWithPartner',
302
304
  ])
303
305
  && typeof value.id === 'string'
@@ -309,7 +311,10 @@ function isGoal(value) {
309
311
  && (value.targetMonthKey === null
310
312
  || (typeof value.targetMonthKey === 'string'
311
313
  && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.targetMonthKey)))
312
- && typeof value.isAchieved === 'boolean'
314
+ && isGoalType(value.goalType)
315
+ && (value.goalType === 'spend'
316
+ ? value.spentAt === null || isIsoDateTime(value.spentAt)
317
+ : value.spentAt === null)
313
318
  && typeof value.sharedWithPartner === 'boolean');
314
319
  }
315
320
  function isCurrency(value) {
@@ -0,0 +1,4 @@
1
+ export const GOAL_TYPES = ['keep', 'spend'];
2
+ export function isGoalType(value) {
3
+ return typeof value === 'string' && GOAL_TYPES.includes(value);
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {