@lorekit/cli 1.53.0 → 1.55.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/README.md CHANGED
@@ -486,6 +486,39 @@ lesson link, `search foo --link` → `/lore?q="foo"` (+ scope), and `list --link
486
486
  multi-scope `list`/`tree` view maps to its primary scope. (The same JSON-encoded
487
487
  links now back the hooks' write-confirmation and retrospective nudges.)
488
488
 
489
+ ### `lorekit purge` / `lorekit purge-expired`
490
+
491
+ The two maintenance sweeps. Both permanently delete rows, and neither can be undone.
492
+
493
+ ```bash
494
+ lorekit purge --retention-days 30 --yes # hard-delete archived lore older than 30 days
495
+ lorekit purge-expired --yes # hard-delete every TTL-expired memory
496
+ ```
497
+
498
+ `purge` removes **archived** memories past a retention window — archived lore is
499
+ hidden from reads but recoverable with `lorekit restore`, so this is the step that
500
+ makes it unrecoverable. `purge-expired` removes memories whose `ttl_days` window
501
+ has passed. `--retention-days` accepts 1–365 and defaults to 30; it is validated
502
+ before any request, so an out-of-range or non-integer value fails immediately
503
+ rather than costing a round trip.
504
+
505
+ **Remote only.** They sweep server-side state and the offline store has no
506
+ equivalent operation, so `--local` is refused with a message rather than quietly
507
+ doing nothing.
508
+
509
+ **Confirmation, not a dry run.** There is no `--dry-run`, and that is not an
510
+ omission: the purge RPCs return their count only *after* deleting, and the REST
511
+ dry-run header stops before the write with nothing to preview — so "would purge
512
+ N" cannot be answered honestly. Instead an interactive terminal is prompted, and
513
+ **`--yes` is required whenever there is nobody to ask** (a pipe, CI, or `--json`).
514
+ An unattended agent cannot purge by omission.
515
+
516
+ **Scoped tokens are refused.** A token restricted to specific scopes cannot run
517
+ an account-wide sweep — there is no scope to check and no result set to narrow.
518
+ The server's refusal is printed verbatim with a one-line next step; the CLI makes
519
+ exactly one request and never retries, splits the sweep, or re-scopes around it.
520
+ Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
521
+
489
522
  ### `lorekit hook`
490
523
 
491
524
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -519,9 +552,20 @@ It speaks JSON-RPC 2.0 over newline-delimited stdin/stdout (the MCP stdio
519
552
  transport, hand-rolled — zero dependencies) and is **not run by hand**: only
520
553
  JSON-RPC frames reach stdout. It serves whatever mode resolves — `local` serves
521
554
  the `.lorekit/` files directly, `remote` passes calls through to the hosted
522
- endpoint, and `off` advertises no tools. Tools advertised: `memory.write`,
523
- `memory.read`, `memory.list`, `memory.search`, `memory.delete`,
524
- `memory.archive`.
555
+ endpoint, and `off` advertises no tools.
556
+
557
+ Tools advertised are **derived from the canonical tool catalog**
558
+ (`packages/schemas/src/tool-catalog.ts`) rather than declared here, so this
559
+ server and the hosted one describe each operation identically. In `local` and
560
+ `remote` mode that is `memory.write`, `memory.read`, `memory.list`,
561
+ `memory.search`, `memory.delete`, `memory.archive`, `memory.restore` and
562
+ `memory.scopes`, plus `org.create`, `org.list`, `org.rename` and `org.delete`
563
+ (which always route to the hosted API). `off` advertises the `org.*` tools only.
564
+
565
+ An operation the catalog declares but this server cannot back — `memory.purge`,
566
+ `memory.purge_expired` and `memory.list_archived` — carries a recorded reason in
567
+ the catalog's `surfaces.localMcpExempt` field, so the gap is a stated decision
568
+ rather than an omission.
525
569
 
526
570
  Wire it into `.mcp.json` as an alternative to the `mcp-remote <url>` transport —
527
571
  this variant needs no endpoint or token for local mode:
@@ -952,12 +996,13 @@ also returns their headroom against the plan's memory cap.
952
996
  | `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
953
997
  | `--force` | Overwrite existing skill files (`install`) |
954
998
  | `--deep` | Write/read/delete round-trip (`doctor`) |
955
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
999
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link` / `purge` / `purge-expired`) |
956
1000
  | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory. On `show` / `write` it **names** the scope, overriding the positional |
957
1001
  | `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
958
1002
  | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
959
1003
  | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
960
1004
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
1005
+ | `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
961
1006
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
962
1007
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
963
1008
  | `-h, --help` | Help |
package/bin/lorekit.mjs CHANGED
@@ -3,26 +3,14 @@
3
3
  import process from 'node:process';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { parseArgs, log, err, c } from '../src/util.mjs';
6
- import { install } from '../src/install.mjs';
7
- import { uninstall } from '../src/uninstall.mjs';
8
- import { doctor } from '../src/doctor.mjs';
9
- import { list } from '../src/list.mjs';
10
- import { search } from '../src/search.mjs';
11
- import { show } from '../src/show.mjs';
12
- import { write } from '../src/write.mjs';
13
- import { archive, del, restore } from '../src/remove.mjs';
14
- import { stats } from '../src/stats.mjs';
15
- import { scopes } from '../src/scopes.mjs';
16
- import { diff } from '../src/diff.mjs';
17
- import { tree } from '../src/tree.mjs';
18
- import { lint } from '../src/lint.mjs';
19
- import { dedupe } from '../src/dedupe.mjs';
20
- import { link } from '../src/link.mjs';
21
- import { hook } from '../src/hook.mjs';
22
- import { migrate } from '../src/migrate.mjs';
23
- import { bootstrap } from '../src/bootstrap.mjs';
24
- import { mcpServer } from '../src/mcp-server.mjs';
25
- import { traceCommand } from '../src/telemetry.mjs';
6
+ // Commands, their handlers, aliases and dispatch properties all come from one
7
+ // registry see ../src/commands.mjs for why membership lives there and the
8
+ // help prose stays here.
9
+ import { COMMANDS_BY_NAME, STRICT_FLAG_COMMANDS, COMMAND_ALIASES } from '../src/commands.mjs';
10
+ // Catalog-derived, so the default this help PROMISES is the one the server
11
+ // applies see src/surfaces.generated.mjs.
12
+ import { PURGE_RETENTION_DAYS_DEFAULT } from '../src/surfaces.generated.mjs';
13
+ import { traceCommand, meterCommand } from '../src/telemetry.mjs';
26
14
  import { loadDotEnv } from '../src/dotenv.mjs';
27
15
 
28
16
  // Read the version from package.json so it always matches the published
@@ -92,6 +80,15 @@ ${c.bold('Commands')}
92
80
  Explorer (--q / --owner / --tags / --range / --archived);
93
81
  --base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
94
82
  lorekit link | pbcopy.
83
+ purge Permanently delete ARCHIVED memories older than --retention-days
84
+ (default ${PURGE_RETENTION_DAYS_DEFAULT}, range 1-365). Remote only, account-wide and
85
+ IRREVERSIBLE: prompts for confirmation, and requires --yes when
86
+ there is no terminal to prompt (a pipe, CI, or --json). A token
87
+ restricted to specific scopes is refused by the server.
88
+ purge-expired
89
+ Permanently delete every TTL-EXPIRED memory. Same posture as
90
+ purge: remote only, account-wide, irreversible, --yes required
91
+ non-interactively. Takes no options.
95
92
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
96
93
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
97
94
  See docs/byod.md for setup instructions.
@@ -122,6 +119,9 @@ ${c.bold('Options')}
122
119
  --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
123
120
  --base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
124
121
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
122
+ --retention-days <1..365>
123
+ Only purge archived memories older than this (purge;
124
+ default ${PURGE_RETENTION_DAYS_DEFAULT})
125
125
  --from <path> Source store to migrate from (migrate)
126
126
  --to <dest> Migration destination: home | project | remote (migrate;
127
127
  default routes each entry by scope across the local tiers)
@@ -645,6 +645,149 @@ ${c.bold('Examples')}
645
645
  npx @lorekit/cli migrate --from .lorekit --to remote # preview the push
646
646
  npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
647
647
  `,
648
+ archive: `${c.bold('lorekit archive')} — hide a memory without losing it
649
+
650
+ ${c.bold('Usage')}
651
+ lorekit archive <scope::key>
652
+ lorekit archive <scope> <key>
653
+ lorekit archive --scope <scope> --key <key>
654
+
655
+ Soft-archives one memory: it stops appearing in reads and in the hooks' injected
656
+ context, but it is still there and ${c.cyan('lorekit restore')} brings it back. Reach for
657
+ this rather than ${c.cyan('delete')} when a lesson has stopped being true — the record of
658
+ having learned it is usually worth keeping.
659
+
660
+ Addresses a memory the same three ways ${c.cyan('write')} / ${c.cyan('show')} do, and picks a store with
661
+ the same precedence (remote when usable, else local; ${c.cyan('--remote')} / ${c.cyan('--local')} force it).
662
+
663
+ ${c.bold('Scoped API tokens')}
664
+ Server-side this is scope-authorized: a token restricted to a scope may archive
665
+ every writer's row in that scope, while an unscoped token may only archive its
666
+ own. A no-match is reported as not-found rather than a silent success.
667
+
668
+ ${c.bold('Options')}
669
+ --scope <scope> Name the scope explicitly
670
+ --key <key> Name the key explicitly — for a key containing \`::\`
671
+ --remote / --local Force a store instead of the usual precedence
672
+ --json Machine-readable result
673
+ `,
674
+
675
+ delete: `${c.bold('lorekit delete')} — archive a memory, or destroy it with --force ${c.dim('(alias: rm)')}
676
+
677
+ ${c.bold('Usage')}
678
+ lorekit delete <scope::key> # soft-archive (reversible)
679
+ lorekit delete <scope::key> --force # hard-delete (unrecoverable)
680
+
681
+ Without ${c.cyan('--force')} this is exactly ${c.cyan('lorekit archive')} — the memory is hidden and
682
+ restorable. With ${c.cyan('--force')} the row is gone and no restore can bring it back.
683
+
684
+ ${c.bold('Scoped API tokens')}
685
+ Same scope authorization as ${c.cyan('archive')}: a scope-restricted token may act on any
686
+ writer's row within its scopes, an unscoped one only on its own, and a 0-row
687
+ result is reported as not-found rather than as success.
688
+
689
+ ${c.bold('Options')}
690
+ --force Hard-delete instead of archiving. Unrecoverable
691
+ --scope <scope> Name the scope explicitly
692
+ --key <key> Name the key explicitly — for a key containing \`::\`
693
+ --remote / --local Force a store instead of the usual precedence
694
+ --json Machine-readable result
695
+ `,
696
+
697
+ restore: `${c.bold('lorekit restore')} — bring an archived memory back
698
+
699
+ ${c.bold('Usage')}
700
+ lorekit restore <scope::key>
701
+ lorekit restore <scope> <key>
702
+
703
+ Un-archives a memory so it appears in reads again. The counterpart to
704
+ ${c.cyan('archive')} (and to ${c.cyan('delete')} without ${c.cyan('--force')}). A memory that was hard-deleted
705
+ cannot be restored — there is nothing left to restore.
706
+
707
+ Restoring something that is already active is not an error; it reports that
708
+ nothing changed.
709
+
710
+ ${c.bold('Options')}
711
+ --scope <scope> Name the scope explicitly
712
+ --key <key> Name the key explicitly — for a key containing \`::\`
713
+ --remote / --local Force a store instead of the usual precedence
714
+ --json Machine-readable result
715
+ `,
716
+
717
+ bootstrap: `${c.bold('lorekit bootstrap')} — apply the LoreKit schema to your own Supabase database
718
+
719
+ ${c.bold('Usage')}
720
+ lorekit bootstrap [--yes]
721
+
722
+ For BYOD ("bring your own database") only: creates the tables, functions and
723
+ policies LoreKit needs in a Supabase project you control, so lore never leaves
724
+ your infrastructure. You only need this if you are pointing the CLI at your own
725
+ database via ${c.cyan('LOREKIT_STORAGE_URL')} / ${c.cyan('LOREKIT_STORAGE_ANON_KEY')} — the hosted
726
+ service and the offline store both need nothing here.
727
+
728
+ See ${c.cyan('docs/byod.md')} for the full setup, including which key to use and what the
729
+ schema contains.
730
+
731
+ ${c.bold('Options')}
732
+ -y, --yes Apply without prompting
733
+ --endpoint <url> Target endpoint
734
+ --token <token> Token for the target
735
+ `,
736
+
737
+ purge: `${c.bold('lorekit purge')} — permanently delete archived memories past a retention window
738
+
739
+ ${c.bold('Usage')}
740
+ lorekit purge [--retention-days <1..365>] [--yes] [--json]
741
+
742
+ Hard-deletes ARCHIVED memories older than the retention window. Archived lore is
743
+ hidden from reads but recoverable with ${c.cyan('lorekit restore')} — this is what makes it
744
+ unrecoverable, so it is the one step in the lifecycle that cannot be walked back.
745
+
746
+ ${c.bold('Remote only')}
747
+ It sweeps server-side state; the offline store has no equivalent, so ${c.cyan('--local')} is
748
+ refused rather than quietly doing nothing.
749
+
750
+ ${c.bold('Confirmation')}
751
+ There is no dry run: the purge RPC returns its count only AFTER deleting, so
752
+ "would purge N" cannot be answered honestly. Instead you are asked to confirm,
753
+ and ${c.cyan('--yes')} is REQUIRED when there is no terminal to ask (a pipe, CI, or --json)
754
+ — an unattended agent must not be able to purge by omission.
755
+
756
+ ${c.bold('Scoped tokens')}
757
+ A token restricted to specific scopes is refused by the server: an account-wide
758
+ sweep has no scope to check and no result set to narrow. Use an unscoped token
759
+ for maintenance.
760
+
761
+ ${c.bold('Options')}
762
+ --retention-days <n> Only purge archived memories older than n days
763
+ (1-365, default ${PURGE_RETENTION_DAYS_DEFAULT})
764
+ -y, --yes Confirm; required when non-interactive
765
+ --json Machine-readable result ({ ok, purged, error })
766
+ -e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
767
+ -t, --token <token> LoreKit token (needs write permission, unscoped)
768
+ `,
769
+
770
+ 'purge-expired': `${c.bold('lorekit purge-expired')} — permanently delete every TTL-expired memory
771
+
772
+ ${c.bold('Usage')}
773
+ lorekit purge-expired [--yes] [--json]
774
+
775
+ Hard-deletes memories whose ${c.cyan('ttl_days')} window has passed. Complementary to
776
+ ${c.cyan('lorekit purge')}, which removes archived rows: this one removes rows that expired
777
+ on their own. Takes no options of its own — the row set is every expired memory
778
+ you own.
779
+
780
+ Same posture as ${c.cyan('purge')}: remote only, account-wide, irreversible, confirmation
781
+ required (${c.cyan('--yes')} when non-interactive), and refused for a token restricted to
782
+ specific scopes.
783
+
784
+ ${c.bold('Options')}
785
+ -y, --yes Confirm; required when non-interactive
786
+ --json Machine-readable result ({ ok, purged, error })
787
+ -e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
788
+ -t, --token <token> LoreKit token (needs write permission, unscoped)
789
+ `,
790
+
648
791
  hook: `${c.bold('lorekit hook')} — hook engine for Claude Code / Cursor / Codex
649
792
 
650
793
  ${c.bold('Usage')}
@@ -688,24 +831,12 @@ const KNOWN_FLAGS = [
688
831
  // error. It is parsed and discarded (nothing reads `args.view`). Remove it once
689
832
  // 1.x links have aged out.
690
833
  'link', 'base', 'q', 'owner', 'range', 'archived', 'view',
834
+ 'retention-days',
691
835
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
692
836
  // Scale-aware survey flags
693
837
  'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
694
838
  ];
695
839
 
696
- // Commands that write to disk / talk to the network on a human's behalf. These
697
- // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
698
- // never fail on a stray flag, and only ever receive flags we control).
699
- const HUMAN_COMMANDS = new Set([
700
- 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
701
- 'diff', 'tree', 'lint', 'dedupe', 'link', 'migrate', 'write',
702
- 'archive', 'delete', 'restore',
703
- ]);
704
-
705
- // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
706
- // and telemetry both resolve to the real command name.
707
- const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree', url: 'link', rm: 'delete' };
708
-
709
840
  async function main() {
710
841
  // Load a `.env` from the current directory (if any) before anything reads the
711
842
  // environment — so telemetry config, tokens, and endpoints can come from a
@@ -730,17 +861,25 @@ async function main() {
730
861
  return 0;
731
862
  }
732
863
 
733
- // `hook` is machine-facing: it must never print help/errors to stdout
734
- // (that would corrupt the JSON the host parses). Handle it before the
735
- // usage branch and always resolve to exit 0.
736
- if (command === 'hook') {
737
- return hook(args);
738
- }
739
-
740
- // `mcp` is machine-facing too: only JSON-RPC frames may reach stdout, so it
741
- // must bypass the usage branch. It serves stdio until the client closes.
742
- if (command === 'mcp') {
743
- return mcpServer(args);
864
+ // Machine-facing commands (`hook`, `mcp`) own their stdout a host's JSON
865
+ // contract and JSON-RPC frames respectively so they must bypass the usage
866
+ // and version branches, which print. `machine` in the registry is the single
867
+ // statement of that.
868
+ //
869
+ // They stay UNTRACED — a span per agent event is a firehose of near-identical
870
+ // traces, and these fire several times per turn — but they are no longer
871
+ // SILENT. `meterCommand` emits the invocation COUNTER only, carrying the same
872
+ // identity attributes the traced commands do, on a much tighter export budget
873
+ // (see `METERED_TIMEOUT_MS`). Without it the durable telemetry identity would
874
+ // differentiate users across `list`/`search`/`stats` while the traffic that
875
+ // actually dominates — the hooks on every turn — stayed invisible.
876
+ //
877
+ // The command runs FIRST and its exit code is returned unchanged: the host's
878
+ // stdout contract is written by `run` before any export is attempted, and a
879
+ // telemetry failure can neither alter the exit code nor corrupt the frame.
880
+ const machineEntry = COMMANDS_BY_NAME.get(command);
881
+ if (machineEntry?.machine) {
882
+ return meterCommand(machineEntry.name, VERSION, () => machineEntry.run(args));
744
883
  }
745
884
 
746
885
  if (args.version) {
@@ -755,61 +894,30 @@ async function main() {
755
894
 
756
895
  // Reject unrecognized flags on human-facing commands with an actionable
757
896
  // pointer, rather than silently ignoring a typo that would change behavior.
758
- if (HUMAN_COMMANDS.has(command) && args._unknown.length > 0) {
897
+ if (STRICT_FLAG_COMMANDS.has(command) && args._unknown.length > 0) {
759
898
  const plural = args._unknown.length > 1 ? 's' : '';
760
899
  err(`${c.red(`Unknown option${plural}:`)} ${args._unknown.join(', ')}`);
761
900
  err(`Run ${c.cyan(`lorekit ${command} --help`)} to see valid options.`);
762
901
  return 1;
763
902
  }
764
903
 
765
- // Human-facing commands are wrapped so we can see which commands people run
766
- // (one OTel span + counter per invocation). `hook` and `mcp` are handled
767
- // above and stay uninstrumented they are machine-facing, fire on every
768
- // agent event, and must keep stdout to their host protocol.
769
- switch (command) {
770
- case 'install':
771
- return traceCommand('install', args, VERSION, () => install(args));
772
- case 'uninstall':
773
- return traceCommand('uninstall', args, VERSION, () => uninstall(args));
774
- case 'doctor':
775
- return traceCommand('doctor', args, VERSION, () => doctor(args));
776
- case 'list':
777
- return traceCommand('list', args, VERSION, () => list(args));
778
- case 'search':
779
- return traceCommand('search', args, VERSION, () => search(args));
780
- case 'show':
781
- return traceCommand('show', args, VERSION, () => show(args));
782
- case 'stats':
783
- return traceCommand('stats', args, VERSION, () => stats(args));
784
- case 'scopes':
785
- return traceCommand('scopes', args, VERSION, () => scopes(args));
786
- case 'diff':
787
- return traceCommand('diff', args, VERSION, () => diff(args));
788
- case 'tree':
789
- return traceCommand('tree', args, VERSION, () => tree(args));
790
- case 'lint':
791
- return traceCommand('lint', args, VERSION, () => lint(args));
792
- case 'dedupe':
793
- return traceCommand('dedupe', args, VERSION, () => dedupe(args));
794
- case 'link':
795
- return traceCommand('link', args, VERSION, () => link(args));
796
- case 'migrate':
797
- return traceCommand('migrate', args, VERSION, () => migrate(args));
798
- case 'bootstrap':
799
- return traceCommand('bootstrap', args, VERSION, () => bootstrap(args));
800
- case 'write':
801
- return traceCommand('write', args, VERSION, () => write(args));
802
- case 'archive':
803
- return traceCommand('archive', args, VERSION, () => archive(args));
804
- case 'delete':
805
- return traceCommand('delete', args, VERSION, () => del(args));
806
- case 'restore':
807
- return traceCommand('restore', args, VERSION, () => restore(args));
808
- default:
809
- err(`${c.red('Unknown command:')} ${command}\n`);
810
- log(HELP);
811
- return 1;
904
+ // Every remaining command is dispatched through `traceCommand`, so one OTel
905
+ // span and one counter are emitted per invocation without any command wiring
906
+ // its own telemetry is INHERITED from this single call site. `hook` and
907
+ // `mcp` returned above and stay uninstrumented by design.
908
+ //
909
+ // This replaced nineteen identical `case` clauses whose only job was to
910
+ // repeat the command name three times. The registry already knows the name
911
+ // and the handler, so adding a command needs no edit here at all — which is
912
+ // what stops the dispatch list and the membership set drifting apart again.
913
+ const entry = COMMANDS_BY_NAME.get(command);
914
+ if (!entry) {
915
+ err(`${c.red('Unknown command:')} ${command}\n`);
916
+ log(HELP);
917
+ return 1;
812
918
  }
919
+
920
+ return traceCommand(entry.name, args, VERSION, () => entry.run(args));
813
921
  }
814
922
 
815
923
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,112 @@
1
+ // The command registry — the ONE hand-written statement of what commands exist.
2
+ //
3
+ // Before this file the same list was spelled out four independent times in
4
+ // `bin/lorekit.mjs`: a `switch` with one `case` per command, a `HUMAN_COMMANDS`
5
+ // set, a `COMMAND_ALIASES` map, and the `COMMAND_HELP` keys. Nothing
6
+ // cross-checked them, and they had already disagreed — `bootstrap` was
7
+ // dispatched by the `switch` but missing from `HUMAN_COMMANDS`, so it silently
8
+ // accepted unknown flags where every sibling rejects them.
9
+ //
10
+ // What this file does NOT own: the help prose. `HELP` and `COMMAND_HELP` stay in
11
+ // `bin/lorekit.mjs`, because they are already in exactly one place — moving ~640
12
+ // lines of editorial text here would relocate risk without removing any
13
+ // duplication. What was duplicated is MEMBERSHIP, and membership is what lives
14
+ // here; a guard asserts every entry below has a help entry there.
15
+ //
16
+ // Two independent properties, deliberately not one `human` flag:
17
+ //
18
+ // traced — dispatched through `traceCommand`, so one OTel span and one
19
+ // counter are emitted per invocation. Telemetry is INHERITED
20
+ // from the dispatcher; a command never wires its own.
21
+ // strictFlags — rejects unknown options with an actionable pointer instead of
22
+ // silently ignoring what might be a typo.
23
+ //
24
+ // `hook` and `mcp` are neither: they are machine-facing, fire on every agent
25
+ // event, and own their stdout (a host's JSON contract / JSON-RPC frames), so a
26
+ // span's cost and an error message's bytes are both unacceptable there. They are
27
+ // marked `machine` and dispatched before the usage branches.
28
+ //
29
+ // `traced: false` does NOT mean unmeasured. The dispatcher routes `machine`
30
+ // commands through `meterCommand`, which emits the invocation COUNTER (with the
31
+ // same identity attributes the traced commands carry) and no span, on a tighter
32
+ // export budget. These two are the highest-volume entry points in the CLI, so
33
+ // leaving them entirely silent meant the usage that dominates was the usage
34
+ // nobody could see — a span each is still the wrong trade, a counter is not.
35
+
36
+ import { install } from './install.mjs';
37
+ import { uninstall } from './uninstall.mjs';
38
+ import { doctor } from './doctor.mjs';
39
+ import { list } from './list.mjs';
40
+ import { search } from './search.mjs';
41
+ import { show } from './show.mjs';
42
+ import { write } from './write.mjs';
43
+ import { archive, del, restore } from './remove.mjs';
44
+ import { stats } from './stats.mjs';
45
+ import { scopes } from './scopes.mjs';
46
+ import { diff } from './diff.mjs';
47
+ import { tree } from './tree.mjs';
48
+ import { lint } from './lint.mjs';
49
+ import { dedupe } from './dedupe.mjs';
50
+ import { link } from './link.mjs';
51
+ import { hook } from './hook.mjs';
52
+ import { migrate } from './migrate.mjs';
53
+ import { bootstrap } from './bootstrap.mjs';
54
+ import { mcpServer } from './mcp-server.mjs';
55
+ import { purge, purgeExpired } from './purge.mjs';
56
+
57
+ /**
58
+ * Every command, in the order the top-level help lists them.
59
+ *
60
+ * `tool` binds a command to a catalog operation (`surfaces.cli` in
61
+ * `packages/schemas/src/tool-catalog.ts`) — the two are cross-checked, so a
62
+ * catalog op claiming a CLI command that does not exist here fails a test.
63
+ * `native` marks a command with no catalog operation and says why, which is
64
+ * most of them: installing, diagnosing and grooming are CLI concerns that no
65
+ * MCP tool corresponds to.
66
+ */
67
+ export const COMMANDS = [
68
+ { name: 'install', run: install, traced: true, strictFlags: true, native: 'scaffolds skills, hooks and MCP config on disk' },
69
+ { name: 'uninstall', run: uninstall, traced: true, strictFlags: true, native: 'removes what install wrote' },
70
+ { name: 'doctor', run: doctor, traced: true, strictFlags: true, native: 'connectivity / token / scope health check' },
71
+ { name: 'list', run: list, traced: true, strictFlags: true, tool: 'memory.list', aliases: ['ls'] },
72
+ { name: 'search', run: search, traced: true, strictFlags: true, tool: 'memory.search', aliases: ['grep'] },
73
+ { name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' },
74
+ { name: 'stats', run: stats, traced: true, strictFlags: true, native: 'local rollup over the resolved store' },
75
+ { name: 'scopes', run: scopes, traced: true, strictFlags: true, tool: 'memory.scopes' },
76
+ { name: 'diff', run: diff, traced: true, strictFlags: true, native: 'compares two scopes' },
77
+ { name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] },
78
+ { name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' },
79
+ { name: 'dedupe', run: dedupe, traced: true, strictFlags: true, native: 'near-duplicate detection across a scope' },
80
+ { name: 'link', run: link, traced: true, strictFlags: true, native: 'builds a dashboard deep link', aliases: ['url'] },
81
+ { name: 'migrate', run: migrate, traced: true, strictFlags: true, native: 'moves lore between local and remote stores' },
82
+ { name: 'bootstrap', run: bootstrap, traced: true, strictFlags: true, native: 'seeds a fresh store from a template' },
83
+ { name: 'write', run: write, traced: true, strictFlags: true, tool: 'memory.write' },
84
+ { name: 'archive', run: archive, traced: true, strictFlags: true, tool: 'memory.archive' },
85
+ { name: 'delete', run: del, traced: true, strictFlags: true, tool: 'memory.delete', aliases: ['rm'] },
86
+ { name: 'restore', run: restore, traced: true, strictFlags: true, tool: 'memory.restore' },
87
+ { name: 'purge', run: purge, traced: true, strictFlags: true, tool: 'memory.purge' },
88
+ { name: 'purge-expired', run: purgeExpired, traced: true, strictFlags: true, tool: 'memory.purge_expired' },
89
+
90
+ // ── Machine-facing ──────────────────────────────────────────────────────────
91
+ { name: 'hook', run: hook, traced: false, strictFlags: false, machine: true, native: 'host hook engine — stdout is the host\'s JSON contract' },
92
+ { name: 'mcp', run: mcpServer, traced: false, strictFlags: false, machine: true, native: 'local stdio MCP server — stdout is JSON-RPC frames' },
93
+ ];
94
+
95
+ /** Command name -> its registry entry. */
96
+ export const COMMANDS_BY_NAME = new Map(COMMANDS.map((entry) => [entry.name, entry]));
97
+
98
+ /**
99
+ * Commands that reject unknown options.
100
+ *
101
+ * They write to disk or talk to the network on a human's behalf, so a typo'd
102
+ * flag must be an error rather than a silently ignored argument that changes
103
+ * what the command does.
104
+ */
105
+ export const STRICT_FLAG_COMMANDS = new Set(
106
+ COMMANDS.filter((entry) => entry.strictFlags).map((entry) => entry.name),
107
+ );
108
+
109
+ /** Alias -> canonical command name, resolved before help, dispatch and telemetry. */
110
+ export const COMMAND_ALIASES = Object.fromEntries(
111
+ COMMANDS.flatMap((entry) => (entry.aliases ?? []).map((alias) => [alias, entry.name])),
112
+ );
package/src/control.mjs CHANGED
@@ -513,7 +513,7 @@ function projectDirFrom({ env, userConfig, repoConfig, root }) {
513
513
  // `store` override). Used by `migrate` so it works regardless of the active
514
514
  // mode. `home` is the per-user tier root; `project` is the opt-in repo tier.
515
515
  export function localStoreDirs(root = process.cwd(), env = process.env) {
516
- const home = userConfigDir(env);
516
+ const home = homeRoot(env);
517
517
  const userConfig = readJson(path.join(home, 'config.json'));
518
518
  const repoConfig = readJson(path.join(root, '.lorekit.json'));
519
519
  return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
@@ -534,7 +534,7 @@ export function resolveDenies(root, { env = process.env } = {}) {
534
534
 
535
535
  // IO wrapper — load env + config files, derive the connection, then resolve.
536
536
  export function loadControl(root, { env = process.env } = {}) {
537
- const home = userConfigDir(env);
537
+ const home = homeRoot(env);
538
538
  const userConfig = readJson(path.join(home, 'config.json'));
539
539
  const repoConfig = readJson(path.join(root, '.lorekit.json'));
540
540
  const conn = resolveProjectConnection(root, splitEndpoint);
@@ -545,9 +545,19 @@ export function loadControl(root, { env = process.env } = {}) {
545
545
  return resolveControl({ env, userConfig, repoConfig, connection, root, home });
546
546
  }
547
547
 
548
- // The per-user home tier root (also holds config.json): $LOREKIT_HOME, default
549
- // `~/.lorekit`. Moved from the old `~/.agent-memory` location.
550
- function userConfigDir(env) {
548
+ /**
549
+ * The per-user home tier root (also holds config.json and telemetry-id.json):
550
+ * $LOREKIT_HOME, default `~/.lorekit`. Moved from the old `~/.agent-memory`
551
+ * location.
552
+ *
553
+ * Exported because `telemetry-identity.mjs` stores the install id in the same
554
+ * directory and must resolve it the SAME way. Re-deriving
555
+ * `LOREKIT_HOME || ~/.lorekit` there would put a second copy of this rule in
556
+ * the tree, and a drift between them would not fail loudly — it would mint a
557
+ * fresh "install" for a user who already had one, in a directory unrelated to
558
+ * their store.
559
+ */
560
+ export function homeRoot(env = process.env) {
551
561
  return env.LOREKIT_HOME || path.join(os.homedir(), '.lorekit');
552
562
  }
553
563
 
package/src/doctor.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  resolveTelemetryTokenSource,
24
24
  probeTelemetryExport,
25
25
  } from './telemetry.mjs';
26
+ import { describeIdentity } from './telemetry-identity.mjs';
26
27
  import { deriveScope } from './scope.mjs';
27
28
  import { loadControl, HOOK_INSTRUCTION_EVENTS } from './control.mjs';
28
29
  import { createStore } from './store/index.mjs';
@@ -537,6 +538,23 @@ async function checkTelemetryExport(args, root, record) {
537
538
 
538
539
  record('info', 'telemetry', `export on → ${config.endpoint} ${c.dim(`(credential from ${source})`)}`);
539
540
 
541
+ // What identity the exported telemetry carries, and where it lives. Reported
542
+ // because the id is otherwise invisible: it is minted silently on first run,
543
+ // and a user who wants to see or reset it needs the path. `describeIdentity`
544
+ // never mints, so running `doctor` cannot itself create the file — the line
545
+ // below reads "not yet minted" on a machine that has only ever run `doctor`.
546
+ const identity = describeIdentity();
547
+ const linked = identity.accountId
548
+ ? `account ${identity.accountId}`
549
+ : 'no account linked yet — any authenticated command links it';
550
+ record(
551
+ 'info',
552
+ 'telemetry',
553
+ identity.installId
554
+ ? `identity: install ${identity.installId} · ${linked} ${c.dim(`(${identity.file} — delete to reset)`)}`
555
+ : `identity: not yet minted ${c.dim(`(will be written to ${identity.file})`)}`,
556
+ );
557
+
540
558
  // The probe writes a real span to a real backend — only on explicit request.
541
559
  if (!required) return;
542
560