@lorekit/cli 1.70.0 → 1.71.1
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 +93 -2
- package/bin/lorekit.mjs +70 -5
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +1 -1
- package/skill/lorekit-memory/SKILL.md +1 -1
- package/src/commands/doctor.mjs +41 -11
- package/src/commands/groom.mjs +4 -1
- package/src/commands/hook.mjs +22 -5
- package/src/commands/policy.mjs +27 -3
- package/src/commands/update.mjs +190 -0
- package/src/commands.mjs +2 -0
- package/src/core/update-notify.mjs +154 -0
- package/src/shared/completions.mjs +3 -0
- package/src/shared/control.mjs +22 -0
- package/src/shared/flags.mjs +73 -0
- package/src/shared/skill-versions.mjs +148 -0
- package/src/store/remote.mjs +54 -11
package/README.md
CHANGED
|
@@ -210,6 +210,27 @@ itself:
|
|
|
210
210
|
A revoked token is a **failure**, not a warning: fix it by creating a new token
|
|
211
211
|
and running `lorekit install --force`, which offers to replace the stored one.
|
|
212
212
|
|
|
213
|
+
### `lorekit update`
|
|
214
|
+
|
|
215
|
+
Offline refresh of the bundled skills (and their hook command string) to the
|
|
216
|
+
version shipped with the running CLI — the fix `doctor`'s outdated-skill
|
|
217
|
+
warning and the SessionStart drift nudge (`updates.notify`) both point at.
|
|
218
|
+
Fully offline: the shipped skill source travels in the same npm tarball as
|
|
219
|
+
this CLI, so "installed vs shipped" is a filesystem version compare, never a
|
|
220
|
+
network call.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
lorekit update # refresh every scope that already has an install
|
|
224
|
+
lorekit update --check # dry run: report drift, write nothing
|
|
225
|
+
lorekit update --project # only the project (.claude/skills) install
|
|
226
|
+
lorekit update --global # only the global (~/.claude/skills) install
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`update` never CREATES a fresh install — that stays `install`'s job, so a
|
|
230
|
+
scope with nothing installed is reported and left alone. Refreshing prunes
|
|
231
|
+
any file the shipped skill no longer ships before re-copying, so a
|
|
232
|
+
dropped rule file doesn't silently outlive the version that removed it.
|
|
233
|
+
|
|
213
234
|
### `lorekit list` (alias `ls`)
|
|
214
235
|
|
|
215
236
|
Shows the lessons that apply to **where you are** — the scopes `deriveScope`
|
|
@@ -646,6 +667,75 @@ The server's refusal is printed verbatim with a one-line next step; the CLI make
|
|
|
646
667
|
exactly one request and never retries, splits the sweep, or re-scopes around it.
|
|
647
668
|
Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
|
|
648
669
|
|
|
670
|
+
### `lorekit groom` / `lorekit policy` / `lorekit protect` (aliases: `pin` / `unpin`)
|
|
671
|
+
|
|
672
|
+
Retention automation — the CLI counterpart to the MCP `groom.*`/`policy.*`/
|
|
673
|
+
`memory.protect` tools and the REST `/groom/*`/`/policies*`/`/protect` routes.
|
|
674
|
+
**Remote only**, matching `purge`: `retention_policies` has no local-store
|
|
675
|
+
equivalent.
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
# Preview what a rule would catch, without changing anything
|
|
679
|
+
lorekit groom --scope repo::acme/app --unseen-days 90
|
|
680
|
+
|
|
681
|
+
# Archive the matches (soft-archive, recoverable via `lorekit restore`)
|
|
682
|
+
lorekit groom --scope repo::acme/app --unseen-days 90 --run --yes
|
|
683
|
+
|
|
684
|
+
# Save it as a policy you can re-run by name, or let it sweep nightly
|
|
685
|
+
lorekit policy create --scope repo::acme/app --name "stale repo lore" \
|
|
686
|
+
--unseen-days 90 --mode review
|
|
687
|
+
lorekit policy list
|
|
688
|
+
lorekit policy update <id> --mode auto --enabled
|
|
689
|
+
lorekit policy delete <id>
|
|
690
|
+
|
|
691
|
+
# Exempt one lesson from every rule and every manual sweep
|
|
692
|
+
lorekit pin repo::acme/app::the-load-bearing-lesson
|
|
693
|
+
lorekit unpin repo::acme/app::the-load-bearing-lesson
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
`groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes]` previews
|
|
697
|
+
by default; `--run` archives. Exactly one of `--policy-id`/`--scope` is
|
|
698
|
+
required. `policy <list|create|update|delete>` manages the SAVED rules
|
|
699
|
+
`--policy-id` runs; `delete` removes the rule only, never the lessons it
|
|
700
|
+
matched.
|
|
701
|
+
|
|
702
|
+
Every condition ANDs together: the five age/activity fields
|
|
703
|
+
(`--min-age-days`/`--unseen-days`/`--max-seen-count`/`--max-read-count`/
|
|
704
|
+
`--max-opened-count`) plus the same **eight dimension filters** the Lore
|
|
705
|
+
Explorer's filter bar offers —
|
|
706
|
+
|
|
707
|
+
| Flag | `--<dim>-mode` values (default) |
|
|
708
|
+
|------|----------------------------------|
|
|
709
|
+
| `--tags <a,b>` | `any` \| `all` \| `none` (`any`) |
|
|
710
|
+
| `--kind <a,b>` | `in` \| `nin` (`in`) |
|
|
711
|
+
| `--host <a,b>` | `in` \| `nin` (`in`) |
|
|
712
|
+
| `--trigger <a,b>` | `in` \| `nin` (`in`) |
|
|
713
|
+
| `--source-agent <a,b>` | `in` \| `nin` (`in`) |
|
|
714
|
+
| `--origin-repo <a,b>` | `in` \| `nin` (`in`) |
|
|
715
|
+
| `--origin-branch <a,b>` | `in` \| `nin` (`in`) |
|
|
716
|
+
| `--origin-pr <a,b>` | `in` \| `nin` (`in`) |
|
|
717
|
+
|
|
718
|
+
— all comma-separated, same convention as `write --tags a,b,c`:
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
lorekit policy create --scope repo::acme/app --name "stale ci review events" \
|
|
722
|
+
--kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
|
|
723
|
+
--min-age-days 30
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
`policy update` pairs every condition — age/count AND every dimension — with
|
|
727
|
+
a `--clear-<field>` boolean (e.g. `--clear-kind`, `--clear-min-age-days`) that
|
|
728
|
+
sends an explicit `null` rather than omitting the field: omitting a field on
|
|
729
|
+
update leaves it unchanged, `null` clears it.
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
lorekit policy update <id> --clear-kind --host reviewer,aw
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`protect <scope::key> [--off]` (or the shorter `pin`/`unpin`) marks a lesson as
|
|
736
|
+
excluded from every policy and every `groom --run`, regardless of which rule
|
|
737
|
+
would otherwise match it — persists until explicitly cleared.
|
|
738
|
+
|
|
649
739
|
### `lorekit hook`
|
|
650
740
|
|
|
651
741
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -1110,8 +1200,8 @@ also returns their headroom against the plan's memory cap.
|
|
|
1110
1200
|
| Flag | Meaning |
|
|
1111
1201
|
|------|---------|
|
|
1112
1202
|
| `-d, --dir <path>` | Target project root (default: cwd) |
|
|
1113
|
-
| `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default) |
|
|
1114
|
-
| `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`) |
|
|
1203
|
+
| `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default). Narrows to the project scope (`update`) |
|
|
1204
|
+
| `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`). Narrows to the global scope (`update`) |
|
|
1115
1205
|
| `-e, --endpoint <url>` | LoreKit MCP endpoint |
|
|
1116
1206
|
| `-t, --token <token>` | LoreKit token |
|
|
1117
1207
|
| `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
|
|
@@ -1125,6 +1215,7 @@ also returns their headroom against the plan's memory cap.
|
|
|
1125
1215
|
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
1126
1216
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
1127
1217
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
1218
|
+
| `--check` | Dry run: report skill-install drift without writing anything (`update`) |
|
|
1128
1219
|
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `invariants candidates` / `link` / `purge` / `purge-expired`) |
|
|
1129
1220
|
| `--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 |
|
|
1130
1221
|
| `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -39,6 +39,11 @@ ${c.bold('Commands')}
|
|
|
39
39
|
other servers, hooks, and settings are left untouched. Prompts
|
|
40
40
|
project vs global; --project / --global choose non-interactively.
|
|
41
41
|
doctor Verify the skill install, remote connectivity, token, and scope.
|
|
42
|
+
update Offline refresh of the bundled skills (and their hook command
|
|
43
|
+
string) to the version shipped with the running CLI — the fix
|
|
44
|
+
for doctor's outdated-skill warning. Never creates a fresh
|
|
45
|
+
install (that's \`install\`'s job); --project / --global narrow
|
|
46
|
+
to one scope; --check reports drift without writing anything.
|
|
42
47
|
list (ls) List the memories that apply to the current directory, split into
|
|
43
48
|
an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
|
|
44
49
|
section (the hosted LoreKit API). Groups by scope (project/branch/repo/global).
|
|
@@ -169,6 +174,7 @@ ${c.bold('Options')}
|
|
|
169
174
|
--force Overwrite existing skill files (install)
|
|
170
175
|
--deep Do a write→read→delete round-trip (doctor)
|
|
171
176
|
--telemetry Verify the OTLP export credential works (doctor)
|
|
177
|
+
--check Report drift without writing anything (update)
|
|
172
178
|
--adapter <name> Host framework for hook: claude | cursor | codex
|
|
173
179
|
--event <name> Host hook event (else read from stdin payload)
|
|
174
180
|
-h, --help Show this help
|
|
@@ -193,6 +199,7 @@ ${c.bold('Examples')}
|
|
|
193
199
|
npx @lorekit/cli install --global # set up memory for every project (~/.claude)
|
|
194
200
|
npx @lorekit/cli uninstall --global # tear that global setup back down
|
|
195
201
|
npx @lorekit/cli doctor --deep
|
|
202
|
+
npx @lorekit/cli update --check # report outdated skills without writing
|
|
196
203
|
npx @lorekit/cli migrate --from .lore # preview a rename
|
|
197
204
|
npx @lorekit/cli migrate --from .lore --to project --yes
|
|
198
205
|
npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
|
|
@@ -302,6 +309,32 @@ ${c.bold('Examples')}
|
|
|
302
309
|
npx @lorekit/cli doctor --deep
|
|
303
310
|
npx @lorekit/cli doctor --telemetry
|
|
304
311
|
npx @lorekit/cli doctor --mode local
|
|
312
|
+
`,
|
|
313
|
+
update: `${c.bold('lorekit update')} — offline refresh of the bundled skills to the shipped version
|
|
314
|
+
|
|
315
|
+
${c.bold('Usage')}
|
|
316
|
+
npx @lorekit/cli update [options]
|
|
317
|
+
|
|
318
|
+
Re-copies every bundled skill (force) into whichever scope(s) already have an
|
|
319
|
+
install, and refreshes that scope's hook command string — the same skill-copy
|
|
320
|
+
path and hook-command call \`install --force\` uses, so the two can never
|
|
321
|
+
disagree on what "installing a skill" means. Fully offline: the shipped skill
|
|
322
|
+
source travels in the same npm tarball as this running CLI, so "installed vs
|
|
323
|
+
shipped" is a filesystem version compare, not a network call. Never creates a
|
|
324
|
+
fresh install — that's \`install\`'s job — so a scope with nothing installed is
|
|
325
|
+
reported and left alone.
|
|
326
|
+
|
|
327
|
+
${c.bold('Options')}
|
|
328
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
329
|
+
--project Only refresh this project's install (.claude/skills)
|
|
330
|
+
--global Only refresh the global install (~/.claude/skills)
|
|
331
|
+
--check Dry run: report drift (installed vs shipped version
|
|
332
|
+
per skill) and write nothing
|
|
333
|
+
|
|
334
|
+
${c.bold('Examples')}
|
|
335
|
+
npx @lorekit/cli update
|
|
336
|
+
npx @lorekit/cli update --check
|
|
337
|
+
npx @lorekit/cli update --global
|
|
305
338
|
`,
|
|
306
339
|
list: `${c.bold('lorekit list')} — list the memories that apply to the current directory ${c.dim('(alias: ls)')}
|
|
307
340
|
|
|
@@ -902,7 +935,10 @@ ${c.bold('Options')}
|
|
|
902
935
|
${c.bold('Usage')}
|
|
903
936
|
lorekit groom --policy-id <id> [--run] [--yes] [--json]
|
|
904
937
|
lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
905
|
-
[--max-read-count <n>] [--max-opened-count <n>]
|
|
938
|
+
[--max-read-count <n>] [--max-opened-count <n>]
|
|
939
|
+
[--tags a,b --tags-mode any|all|none]
|
|
940
|
+
[--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
|
|
941
|
+
[--run] [--yes] [--json]
|
|
906
942
|
|
|
907
943
|
Resolves the SAME candidates a saved policy or an inline condition set would
|
|
908
944
|
catch, via the retention-policy candidate query — a previewed count always
|
|
@@ -927,6 +963,12 @@ ${c.bold('Options')}
|
|
|
927
963
|
the hundreds — a small value matches nothing
|
|
928
964
|
--max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
|
|
929
965
|
times. Bulk reads do NOT count, so 0 means never chosen
|
|
966
|
+
--tags <a,b>, --tags-mode <any|all|none>
|
|
967
|
+
Match lessons carrying these labels (default: any)
|
|
968
|
+
--kind, --host, --trigger, --source-agent,
|
|
969
|
+
--origin-repo, --origin-branch, --origin-pr <a,b>
|
|
970
|
+
--<dim>-mode <in|nin> Match lessons whose dimension is one of these
|
|
971
|
+
values (default: in); comma-separated
|
|
930
972
|
--run Archive the matches instead of previewing
|
|
931
973
|
-y, --yes Confirm --run; required when non-interactive
|
|
932
974
|
--json Machine-readable result
|
|
@@ -941,12 +983,17 @@ ${c.bold('Usage')}
|
|
|
941
983
|
lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
|
|
942
984
|
[--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
943
985
|
[--max-read-count <n>] [--max-opened-count <n>]
|
|
986
|
+
[--tags a,b --tags-mode any|all|none]
|
|
987
|
+
[--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
|
|
944
988
|
lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
|
|
945
|
-
[--min-age-days <n>|--clear-min-age-days] [...]
|
|
989
|
+
[--min-age-days <n>|--clear-min-age-days] [...]
|
|
990
|
+
[--tags a,b|--clear-tags] [--<dim> a,b|--clear-<dim>] [--<dim>-mode in|nin] [--json]
|
|
946
991
|
lorekit policy delete <id> [--yes] [--json]
|
|
947
992
|
|
|
948
993
|
A policy is a saved retention rule: a scope plus AND-ed conditions
|
|
949
|
-
(min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count
|
|
994
|
+
(min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count,
|
|
995
|
+
plus the eight dimension filters — tags/kind/host/trigger/source-agent/
|
|
996
|
+
origin-repo/origin-branch/origin-pr).
|
|
950
997
|
\`mode: review\` surfaces it for
|
|
951
998
|
you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
|
|
952
999
|
nightly, but ONLY once you also pass --enabled — auto starts disabled on
|
|
@@ -962,8 +1009,14 @@ ${c.bold('Options')}
|
|
|
962
1009
|
--min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
|
|
963
1010
|
--max-opened-count <n>
|
|
964
1011
|
Conditions (create/update)
|
|
1012
|
+
--tags <a,b>, --tags-mode <any|all|none>
|
|
1013
|
+
--kind, --host, --trigger, --source-agent,
|
|
1014
|
+
--origin-repo, --origin-branch, --origin-pr <a,b>
|
|
1015
|
+
--<dim>-mode <in|nin> Dimension conditions (create/update); comma-separated
|
|
965
1016
|
--clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
|
|
966
|
-
--clear-max-read-count, --clear-max-opened-count
|
|
1017
|
+
--clear-max-read-count, --clear-max-opened-count,
|
|
1018
|
+
--clear-tags, --clear-kind, --clear-host, --clear-trigger,
|
|
1019
|
+
--clear-source-agent, --clear-origin-repo, --clear-origin-branch, --clear-origin-pr
|
|
967
1020
|
Remove a condition (update only)
|
|
968
1021
|
-y, --yes Confirm delete; required when non-interactive
|
|
969
1022
|
--json Machine-readable result
|
|
@@ -1088,8 +1141,20 @@ const KNOWN_FLAGS = [
|
|
|
1088
1141
|
'name', 'mode', 'enabled', 'disabled',
|
|
1089
1142
|
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
1090
1143
|
'clear-max-opened-count', 'off',
|
|
1144
|
+
// The eight retention dimension filters' `*-mode` value flags (the value
|
|
1145
|
+
// flags themselves — tags/source-agent/trigger/kind/host/origin-repo/
|
|
1146
|
+
// origin-branch/origin-pr — are already listed above for `write`) plus
|
|
1147
|
+
// their `clear-*` booleans (policy update only). See
|
|
1148
|
+
// `shared/flags.mjs`'s `parseDimensionConditions`, the one place that
|
|
1149
|
+
// owns the flag-name ↔ field-name ↔ mode-enum table these mirror.
|
|
1150
|
+
'tags-mode', 'source-agent-mode', 'trigger-mode', 'kind-mode', 'host-mode',
|
|
1151
|
+
'origin-repo-mode', 'origin-branch-mode', 'origin-pr-mode',
|
|
1152
|
+
'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host',
|
|
1153
|
+
'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr',
|
|
1091
1154
|
// `obligations`
|
|
1092
1155
|
'files', 'strict', 'strict-all',
|
|
1156
|
+
// `update`
|
|
1157
|
+
'check',
|
|
1093
1158
|
];
|
|
1094
1159
|
|
|
1095
1160
|
async function main() {
|
|
@@ -1102,7 +1167,7 @@ async function main() {
|
|
|
1102
1167
|
const argv = process.argv.slice(2);
|
|
1103
1168
|
const args = parseArgs(argv, {
|
|
1104
1169
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
1105
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all'],
|
|
1170
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host', 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr', 'check'],
|
|
1106
1171
|
known: KNOWN_FLAGS,
|
|
1107
1172
|
});
|
|
1108
1173
|
|
package/package.json
CHANGED
package/src/commands/doctor.mjs
CHANGED
|
@@ -7,7 +7,6 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import {
|
|
8
8
|
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
|
-
skillInstallDir,
|
|
11
10
|
CLAUDE_HOOK_EVENTS,
|
|
12
11
|
installedHookEvents,
|
|
13
12
|
hookModeFromEvents,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
tokenKind,
|
|
18
17
|
readLorekitJson,
|
|
19
18
|
} from '../shared/config.mjs';
|
|
19
|
+
import { checkSkillVersions } from '../shared/skill-versions.mjs';
|
|
20
20
|
import { splitEndpoint } from '../shared/mcp.mjs';
|
|
21
21
|
import {
|
|
22
22
|
resolveTelemetryConfig,
|
|
@@ -71,18 +71,48 @@ export async function doctor(args) {
|
|
|
71
71
|
// skill under home, not the repo, so a project-only check reports a healthy
|
|
72
72
|
// global install as "not found" (exactly the false FAIL a --global setup would
|
|
73
73
|
// hit).
|
|
74
|
+
//
|
|
75
|
+
// Beyond existence, each installed scope now reports its VERSION against the
|
|
76
|
+
// one this CLI ships (`checkSkillVersions` — an offline compare of
|
|
77
|
+
// `metadata.version` in SKILL.md's frontmatter, see `shared/skill-versions.mjs`).
|
|
78
|
+
// An `outdated` or `unknown` (legacy, unparseable) install is a `warn`, never a
|
|
79
|
+
// `fail` — the skill still works, it just has no signal to update without this
|
|
80
|
+
// line — and names the fix scope-aware, mirroring the hooks-upgrade wording
|
|
81
|
+
// above: `lorekit update` is what a stale install is missing, the same way
|
|
82
|
+
// `lorekit install --hooks <mode>` is what a stale hook wiring is missing.
|
|
83
|
+
const skillVersions = checkSkillVersions(root);
|
|
74
84
|
for (const skill of SKILLS) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.map((dir) => path.join(dir, 'SKILL.md'))
|
|
80
|
-
.find((p) => fs.existsSync(p));
|
|
81
|
-
if (skillMd) {
|
|
82
|
-
const rel = path.relative(root, skillMd);
|
|
83
|
-
record('pass', `skill ${skill.name}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
|
|
84
|
-
} else {
|
|
85
|
+
const installedEntries = skillVersions.filter(
|
|
86
|
+
(r) => r.name === skill.name && r.state !== 'not-installed',
|
|
87
|
+
);
|
|
88
|
+
if (installedEntries.length === 0) {
|
|
85
89
|
record('fail', `skill ${skill.name}`, 'not found — run `lorekit install`');
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of installedEntries) {
|
|
93
|
+
const rel = path.relative(root, entry.installedPath);
|
|
94
|
+
const displayPath = rel && !rel.startsWith('..') ? rel : prettyPath(entry.installedPath);
|
|
95
|
+
// Only distinguish the scope in the label when the skill is installed in
|
|
96
|
+
// more than one — the common single-scope case keeps the pre-existing
|
|
97
|
+
// `skill <name>` label so it doesn't churn every doctor transcript.
|
|
98
|
+
const label = installedEntries.length > 1 ? `skill ${skill.name} (${entry.scope})` : `skill ${skill.name}`;
|
|
99
|
+
if (entry.state === 'current') {
|
|
100
|
+
record('pass', label, `${displayPath} — v${entry.installed}`);
|
|
101
|
+
} else if (entry.state === 'outdated') {
|
|
102
|
+
record(
|
|
103
|
+
'warn',
|
|
104
|
+
label,
|
|
105
|
+
`${displayPath} — v${entry.installed}, shipped v${entry.shipped} — ` +
|
|
106
|
+
`run \`lorekit update --${entry.scope}\` to refresh`,
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
record(
|
|
110
|
+
'warn',
|
|
111
|
+
label,
|
|
112
|
+
`${displayPath} — version unknown (legacy install predates version stamping) — ` +
|
|
113
|
+
`run \`lorekit update --${entry.scope}\` to refresh`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
86
116
|
}
|
|
87
117
|
}
|
|
88
118
|
|
package/src/commands/groom.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveProjectRoot } from '../shared/config.mjs';
|
|
|
11
11
|
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
12
12
|
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
13
13
|
import { log, err, c, select } from '../shared/util.mjs';
|
|
14
|
-
import { parseIntFlag } from '../shared/flags.mjs';
|
|
14
|
+
import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
|
|
15
15
|
|
|
16
16
|
/** Resolve the groom.preview/groom.run request from CLI args. */
|
|
17
17
|
export function parseGroomRequest(args) {
|
|
@@ -33,6 +33,8 @@ export function parseGroomRequest(args) {
|
|
|
33
33
|
if (maxRead.error) return { error: maxRead.error };
|
|
34
34
|
const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
|
|
35
35
|
if (maxOpened.error) return { error: maxOpened.error };
|
|
36
|
+
const dims = parseDimensionConditions(args, { clearable: false });
|
|
37
|
+
if (dims.error) return { error: dims.error };
|
|
36
38
|
|
|
37
39
|
return {
|
|
38
40
|
request: {
|
|
@@ -42,6 +44,7 @@ export function parseGroomRequest(args) {
|
|
|
42
44
|
max_seen_count: maxSeen.value,
|
|
43
45
|
max_read_count: maxRead.value,
|
|
44
46
|
max_opened_count: maxOpened.value,
|
|
47
|
+
...dims.conditions,
|
|
45
48
|
},
|
|
46
49
|
};
|
|
47
50
|
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
recordShownLessons,
|
|
31
31
|
} from '../core/state.mjs';
|
|
32
32
|
import { recordFixture } from '../core/record.mjs';
|
|
33
|
+
import { resolveUpdateNudge } from '../core/update-notify.mjs';
|
|
33
34
|
import { claude } from '../adapters/claude.mjs';
|
|
34
35
|
import { cursor } from '../adapters/cursor.mjs';
|
|
35
36
|
import { codex } from '../adapters/codex.mjs';
|
|
@@ -62,6 +63,20 @@ function hookMeterAttrs(meter) {
|
|
|
62
63
|
return attrs;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// Append the offline skill-update nudge to the SessionStart block, when the
|
|
67
|
+
// throttle allows it. Best-effort by design — a broken drift check or a
|
|
68
|
+
// throttle-file write failure must never cost the reader their lessons block,
|
|
69
|
+
// so any error here silently falls back to `text` unmodified.
|
|
70
|
+
function appendUpdateNudge(text, root, control) {
|
|
71
|
+
try {
|
|
72
|
+
const nudge = resolveUpdateNudge(root, { notify: control.updatesNotify });
|
|
73
|
+
if (!nudge) return text;
|
|
74
|
+
return text ? `${text}\n\n${nudge}` : nudge;
|
|
75
|
+
} catch {
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
function readStdin() {
|
|
66
81
|
return new Promise((resolve) => {
|
|
67
82
|
let data = '';
|
|
@@ -164,9 +179,10 @@ async function run(args, meter) {
|
|
|
164
179
|
// No store: emit a minimal header + instruction when present, then return.
|
|
165
180
|
// A SessionStart that cannot read lore is the highest-value failure to see.
|
|
166
181
|
meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
const base = sessionInstruction
|
|
183
|
+
? formatLessons(null, { repoScope: null }, { instruction: sessionInstruction })
|
|
184
|
+
: null;
|
|
185
|
+
emit(appendUpdateNudge(base, root, control));
|
|
170
186
|
return 0;
|
|
171
187
|
}
|
|
172
188
|
const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
|
|
@@ -174,7 +190,7 @@ async function run(args, meter) {
|
|
|
174
190
|
branchHint: control.hooksSessionStartBranchHint !== 'off',
|
|
175
191
|
maxLessons: control.hooksSessionStartMaxLessons,
|
|
176
192
|
});
|
|
177
|
-
|
|
193
|
+
const lessonsBlock = formatLessons(lessons, readScope, {
|
|
178
194
|
instruction: sessionInstruction,
|
|
179
195
|
mode: control.hooksSessionStart,
|
|
180
196
|
maxChars: control.hooksSessionStartMaxChars,
|
|
@@ -192,7 +208,8 @@ async function run(args, meter) {
|
|
|
192
208
|
// `recordShownLessons` never throws, and a failure costs at most one
|
|
193
209
|
// repeated lesson later in the session.
|
|
194
210
|
onShown: (rendered) => recordShownLessons(parsed.sessionId, rendered.map(lessonId)),
|
|
195
|
-
})
|
|
211
|
+
});
|
|
212
|
+
emit(appendUpdateNudge(lessonsBlock, root, control));
|
|
196
213
|
return 0;
|
|
197
214
|
}
|
|
198
215
|
|
package/src/commands/policy.mjs
CHANGED
|
@@ -15,7 +15,19 @@ import { resolveProjectRoot } from '../shared/config.mjs';
|
|
|
15
15
|
import { loadControl, resolveDenies } from '../shared/control.mjs';
|
|
16
16
|
import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
|
|
17
17
|
import { log, err, c, select } from '../shared/util.mjs';
|
|
18
|
-
import { parseIntFlag } from '../shared/flags.mjs';
|
|
18
|
+
import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
|
|
19
|
+
|
|
20
|
+
/** `field` → display label for `formatPolicy`'s dimension summary. */
|
|
21
|
+
const DIMENSION_DISPLAY = [
|
|
22
|
+
['tags', 'tags_mode', 'tags'],
|
|
23
|
+
['source_agent', 'source_agent_mode', 'source_agent'],
|
|
24
|
+
['trigger', 'trigger_mode', 'trigger'],
|
|
25
|
+
['kind', 'kind_mode', 'kind'],
|
|
26
|
+
['host', 'host_mode', 'host'],
|
|
27
|
+
['origin_repo', 'origin_repo_mode', 'origin_repo'],
|
|
28
|
+
['origin_branch', 'origin_branch_mode', 'origin_branch'],
|
|
29
|
+
['origin_pr', 'origin_pr_mode', 'origin_pr'],
|
|
30
|
+
];
|
|
19
31
|
|
|
20
32
|
function pickRemote({ root, env, args }) {
|
|
21
33
|
const { remoteDenied } = resolveDenies(root, { env });
|
|
@@ -32,6 +44,12 @@ function formatPolicy(p) {
|
|
|
32
44
|
if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
|
|
33
45
|
if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
|
|
34
46
|
if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
|
|
47
|
+
for (const [field, modeField, label] of DIMENSION_DISPLAY) {
|
|
48
|
+
const values = p[field];
|
|
49
|
+
if (!Array.isArray(values) || values.length === 0) continue;
|
|
50
|
+
const mode = p[modeField];
|
|
51
|
+
conditions.push(`${label}${mode ? `:${mode}` : ''}=[${values.join(',')}]`);
|
|
52
|
+
}
|
|
35
53
|
const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
|
|
36
54
|
return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
|
|
37
55
|
}
|
|
@@ -52,7 +70,7 @@ async function list(args, store) {
|
|
|
52
70
|
|
|
53
71
|
async function create(args, store) {
|
|
54
72
|
if (!args.scope || !args.name) {
|
|
55
|
-
err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
|
|
73
|
+
err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N] [--tags a,b --tags-mode any|all|none] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]`);
|
|
56
74
|
return 1;
|
|
57
75
|
}
|
|
58
76
|
const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
|
|
@@ -69,6 +87,8 @@ async function create(args, store) {
|
|
|
69
87
|
err(`${c.red('Error:')} --mode must be "review" or "auto"`);
|
|
70
88
|
return 1;
|
|
71
89
|
}
|
|
90
|
+
const dims = parseDimensionConditions(args, { clearable: false });
|
|
91
|
+
if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
|
|
72
92
|
|
|
73
93
|
const res = await store.policyCreate({
|
|
74
94
|
scope: args.scope,
|
|
@@ -80,6 +100,7 @@ async function create(args, store) {
|
|
|
80
100
|
max_seen_count: maxSeen.value,
|
|
81
101
|
max_read_count: maxRead.value,
|
|
82
102
|
max_opened_count: maxOpened.value,
|
|
103
|
+
...dims.conditions,
|
|
83
104
|
});
|
|
84
105
|
if (!res.ok) {
|
|
85
106
|
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
@@ -95,7 +116,7 @@ async function create(args, store) {
|
|
|
95
116
|
async function update(args, store) {
|
|
96
117
|
const id = args._[2];
|
|
97
118
|
if (!id) {
|
|
98
|
-
err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [
|
|
119
|
+
err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N|--clear-min-age-days] [...] [--tags a,b|--clear-tags] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b|--clear-<dim>] [--<dim>-mode ...]`);
|
|
99
120
|
return 1;
|
|
100
121
|
}
|
|
101
122
|
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
@@ -121,6 +142,9 @@ async function update(args, store) {
|
|
|
121
142
|
if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); return 1; }
|
|
122
143
|
patch[field] = parsed.value;
|
|
123
144
|
}
|
|
145
|
+
const dims = parseDimensionConditions(args, { clearable: true });
|
|
146
|
+
if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
|
|
147
|
+
Object.assign(patch, dims.conditions);
|
|
124
148
|
if (Object.keys(patch).length === 0) {
|
|
125
149
|
err(`${c.red('Error:')} at least one field to update is required`);
|
|
126
150
|
return 1;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `lorekit update` — refresh the bundled skills (and their hook wiring) to the
|
|
2
|
+
// versions shipped with the running CLI.
|
|
3
|
+
//
|
|
4
|
+
// Fully offline: the shipped skill source travels in the SAME npm tarball as
|
|
5
|
+
// this running CLI (`SKILLS[].source` in `shared/config.mjs`), so "installed
|
|
6
|
+
// vs shipped" is a filesystem compare with zero network calls — see
|
|
7
|
+
// `shared/skill-versions.mjs`, the same module `doctor` and the SessionStart
|
|
8
|
+
// drift nudge (`core/update-notify.mjs`) read through, so all three surfaces
|
|
9
|
+
// agree on what counts as outdated.
|
|
10
|
+
//
|
|
11
|
+
// `--check` is a dry run: report drift, write nothing. Without it, `update`
|
|
12
|
+
// first PRUNES any file present in the install that the shipped skill no
|
|
13
|
+
// longer ships (`pruneRemoved` — `copyDir` only ever writes, so without this
|
|
14
|
+
// a rule file a newer version dropped would survive every future refresh
|
|
15
|
+
// forever), then re-copies (force) every skill into whichever scope(s)
|
|
16
|
+
// already have an install — reusing `copyDir`, the exact same skill-copy path
|
|
17
|
+
// `install` uses, so the two can never drift on what "installing a skill"
|
|
18
|
+
// means — and refreshes that scope's hook command string via `upsertClaudeHooks`, the
|
|
19
|
+
// same call `install --force` makes. That refresh is deliberately in scope:
|
|
20
|
+
// a stale `npx -y @lorekit/cli@1.2.3 hook …` pin or an old runner path is the
|
|
21
|
+
// same class of drift this command exists to fix, the call is idempotent
|
|
22
|
+
// (it only rewrites the command string, never the wired event set), and
|
|
23
|
+
// skipping it would leave `update` unable to repair the one other thing an
|
|
24
|
+
// install can go stale on. `--project` / `--global` narrow to one scope;
|
|
25
|
+
// with neither, every scope that currently has an existing skill install is
|
|
26
|
+
// refreshed — `update` never CREATES a fresh install, that stays `install`'s
|
|
27
|
+
// job.
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import {
|
|
31
|
+
SKILLS,
|
|
32
|
+
resolveProjectRoot,
|
|
33
|
+
skillInstallDir,
|
|
34
|
+
copyDir,
|
|
35
|
+
installedHookEvents,
|
|
36
|
+
upsertClaudeHooks,
|
|
37
|
+
resolveHookRunner,
|
|
38
|
+
} from '../shared/config.mjs';
|
|
39
|
+
import { checkSkillVersions, SKILL_SCOPES } from '../shared/skill-versions.mjs';
|
|
40
|
+
import { log, heading, status, c } from '../shared/util.mjs';
|
|
41
|
+
|
|
42
|
+
// Which scopes `update` should touch. Explicit `--project`/`--global` narrow
|
|
43
|
+
// to exactly one — but only when that scope actually has something installed;
|
|
44
|
+
// naming a scope with nothing in it must still fall through to the "nothing
|
|
45
|
+
// to update" report below, never silently report health on an empty scope.
|
|
46
|
+
// `--global` is checked first, matching `install`/`uninstall`'s own
|
|
47
|
+
// scope-flag precedence, so `--project --global` never picks the opposite
|
|
48
|
+
// scope from its sibling commands.
|
|
49
|
+
// With neither flag, every scope holding at least one installed skill (any
|
|
50
|
+
// state other than `not-installed`) is refreshed.
|
|
51
|
+
function targetScopes(args, results) {
|
|
52
|
+
const hasInstall = (scope) => results.some((r) => r.scope === scope && r.state !== 'not-installed');
|
|
53
|
+
if (args.global) return hasInstall('global') ? ['global'] : [];
|
|
54
|
+
if (args.project) return hasInstall('project') ? ['project'] : [];
|
|
55
|
+
return SKILL_SCOPES.filter(hasInstall);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Remove any file under `dest` that no longer exists in `src` before the
|
|
59
|
+
// refresh copy. `copyDir` only ever WRITES — it has no delete path — so a
|
|
60
|
+
// rule/reference file a newer skill version dropped would otherwise survive
|
|
61
|
+
// every future `update` forever, still sitting on disk and still read by the
|
|
62
|
+
// agent alongside the content that superseded it, while `update` reports a
|
|
63
|
+
// clean "already up to date" or a green "refreshed". Safe to prune
|
|
64
|
+
// unconditionally: `update` never touches a scope the caller didn't already
|
|
65
|
+
// have installed, and the refresh that follows overwrites everything that
|
|
66
|
+
// DOES still exist in `src` anyway (`--force`), so nothing reachable from the
|
|
67
|
+
// shipped skill is ever at risk — only content the shipped skill no longer
|
|
68
|
+
// ships is removed.
|
|
69
|
+
// `dryRun: true` counts what WOULD be removed without touching disk — the
|
|
70
|
+
// preview `--check` shows for the one destructive step `update` takes, so a
|
|
71
|
+
// dry run never has to say "outdated" and stay silent about a file the real
|
|
72
|
+
// run is about to delete.
|
|
73
|
+
function pruneRemoved(src, dest, { dryRun = false } = {}) {
|
|
74
|
+
if (!fs.existsSync(dest)) return 0;
|
|
75
|
+
let removed = 0;
|
|
76
|
+
for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
77
|
+
const destPath = path.join(dest, entry.name);
|
|
78
|
+
const srcPath = path.join(src, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
|
|
81
|
+
removed += pruneRemoved(srcPath, destPath, { dryRun });
|
|
82
|
+
} else {
|
|
83
|
+
if (!dryRun) fs.rmSync(destPath, { recursive: true, force: true });
|
|
84
|
+
removed++;
|
|
85
|
+
}
|
|
86
|
+
} else if (!fs.existsSync(srcPath)) {
|
|
87
|
+
if (!dryRun) fs.rmSync(destPath, { force: true });
|
|
88
|
+
removed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return removed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const versionLabel = (v) => (v ? `v${v}` : 'unknown');
|
|
95
|
+
|
|
96
|
+
export async function update(args) {
|
|
97
|
+
const root = resolveProjectRoot(args.dir);
|
|
98
|
+
const dryRun = Boolean(args.check);
|
|
99
|
+
|
|
100
|
+
heading(dryRun ? 'LoreKit update (--check)' : 'LoreKit update');
|
|
101
|
+
log(` project: ${c.dim(root)}`);
|
|
102
|
+
|
|
103
|
+
const results = checkSkillVersions(root);
|
|
104
|
+
const scopes = targetScopes(args, results);
|
|
105
|
+
|
|
106
|
+
if (scopes.length === 0) {
|
|
107
|
+
log('');
|
|
108
|
+
log(` ${c.dim('no existing skill install found — nothing to update.')}`);
|
|
109
|
+
log(` Run ${c.cyan('lorekit install')} for a fresh install.`);
|
|
110
|
+
return { exitCode: 0, 'lorekit.cli.update.scopes': 0, 'lorekit.cli.update.outdated': 0, 'lorekit.cli.update.check': dryRun };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
heading('Skills');
|
|
114
|
+
let outdatedCount = 0;
|
|
115
|
+
let filesWritten = 0;
|
|
116
|
+
let filesRemoved = 0;
|
|
117
|
+
for (const scope of scopes) {
|
|
118
|
+
for (const skill of SKILLS) {
|
|
119
|
+
const entry = results.find((r) => r.scope === scope && r.name === skill.name);
|
|
120
|
+
if (!entry || entry.state === 'not-installed') continue;
|
|
121
|
+
const label = scopes.length > 1 ? `skill ${skill.name} (${scope})` : `skill ${skill.name}`;
|
|
122
|
+
|
|
123
|
+
if (entry.state === 'current') {
|
|
124
|
+
status('pass', label, `${versionLabel(entry.installed)} — already up to date`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
outdatedCount++;
|
|
129
|
+
const before = versionLabel(entry.installed);
|
|
130
|
+
const after = versionLabel(entry.shipped);
|
|
131
|
+
const dest = skillInstallDir(root, scope, skill.name);
|
|
132
|
+
if (dryRun) {
|
|
133
|
+
const wouldRemove = pruneRemoved(skill.source, dest, { dryRun: true });
|
|
134
|
+
const removedNote = wouldRemove > 0 ? `, ${wouldRemove} to remove` : '';
|
|
135
|
+
status('warn', label, `${before} → ${after} available${removedNote} — run \`lorekit update\` to refresh`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const removed = pruneRemoved(skill.source, dest);
|
|
140
|
+
const written = copyDir(skill.source, dest, { force: true });
|
|
141
|
+
filesWritten += written;
|
|
142
|
+
filesRemoved += removed;
|
|
143
|
+
const removedNote = removed > 0 ? `, ${removed} removed` : '';
|
|
144
|
+
status('pass', label, `${before} → ${after} (${written} file(s) written${removedNote})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Hook command strings — refreshed for real, never on a dry run. Only a
|
|
149
|
+
// scope that already has hooks wired has anything to refresh; a scope with
|
|
150
|
+
// none stays untouched (matching `install`'s own "nothing to wire" no-op).
|
|
151
|
+
// `upsertClaudeHooks` rewrites `.claude/settings.json` unconditionally
|
|
152
|
+
// (formatting included) even when every entry was already `unchanged` —
|
|
153
|
+
// tallying its returned counts is what lets the no-skills-outdated report
|
|
154
|
+
// below say so instead of silently rewriting the file underneath the user.
|
|
155
|
+
let hooksChanged = 0;
|
|
156
|
+
if (!dryRun) {
|
|
157
|
+
for (const scope of scopes) {
|
|
158
|
+
const wired = installedHookEvents(root, scope);
|
|
159
|
+
if (wired.length === 0) continue;
|
|
160
|
+
const stats = upsertClaudeHooks(root, scope, resolveHookRunner(), wired);
|
|
161
|
+
hooksChanged += stats.added + stats.updated + stats.removed + stats.deduped;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
log('');
|
|
166
|
+
if (outdatedCount === 0) {
|
|
167
|
+
const hooksNote = hooksChanged > 0 ? ` (hook wiring refreshed: ${hooksChanged} change${hooksChanged === 1 ? '' : 's'})` : '';
|
|
168
|
+
log(` ${c.green('✓')} every installed skill is already at the shipped version${hooksNote}.`);
|
|
169
|
+
} else if (dryRun) {
|
|
170
|
+
const plural = outdatedCount === 1 ? '' : 's';
|
|
171
|
+
log(` ${c.yellow('!')} ${outdatedCount} skill install${plural} outdated — run \`lorekit update\` to apply.`);
|
|
172
|
+
} else {
|
|
173
|
+
const plural = outdatedCount === 1 ? '' : 's';
|
|
174
|
+
const removedNote = filesRemoved > 0 ? `, ${filesRemoved} removed` : '';
|
|
175
|
+
log(` ${c.green('✓')} refreshed ${outdatedCount} skill install${plural} (${filesWritten} file(s) written${removedNote}).`);
|
|
176
|
+
}
|
|
177
|
+
log('');
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
// Non-zero only for a `--check` run that found drift — a real run always
|
|
181
|
+
// finishes at 0 (it just fixed whatever it found), matching `doctor`'s own
|
|
182
|
+
// deliberate warn-never-fail posture. This is what lets `--check` gate a
|
|
183
|
+
// CI step or pre-commit hook on "everything is current" instead of only
|
|
184
|
+
// ever reporting drift for a human to notice.
|
|
185
|
+
exitCode: dryRun && outdatedCount > 0 ? 1 : 0,
|
|
186
|
+
'lorekit.cli.update.scopes': scopes.length,
|
|
187
|
+
'lorekit.cli.update.outdated': outdatedCount,
|
|
188
|
+
'lorekit.cli.update.check': dryRun,
|
|
189
|
+
};
|
|
190
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { install } from './commands/install.mjs';
|
|
37
37
|
import { uninstall } from './commands/uninstall.mjs';
|
|
38
38
|
import { doctor } from './commands/doctor.mjs';
|
|
39
|
+
import { update } from './commands/update.mjs';
|
|
39
40
|
import { list } from './commands/list.mjs';
|
|
40
41
|
import { search } from './commands/search.mjs';
|
|
41
42
|
import { show } from './commands/show.mjs';
|
|
@@ -74,6 +75,7 @@ export const COMMANDS = [
|
|
|
74
75
|
{ name: 'install', run: install, traced: true, strictFlags: true, native: 'scaffolds skills, hooks and MCP config on disk' },
|
|
75
76
|
{ name: 'uninstall', run: uninstall, traced: true, strictFlags: true, native: 'removes what install wrote' },
|
|
76
77
|
{ name: 'doctor', run: doctor, traced: true, strictFlags: true, native: 'connectivity / token / scope health check' },
|
|
78
|
+
{ name: 'update', run: update, traced: true, strictFlags: true, native: 'offline refresh of the bundled skills + hook wiring' },
|
|
77
79
|
{ name: 'list', run: list, traced: true, strictFlags: true, tool: 'memory.list', aliases: ['ls'] },
|
|
78
80
|
{ name: 'search', run: search, traced: true, strictFlags: true, tool: 'memory.search', aliases: ['grep'] },
|
|
79
81
|
{ name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' },
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SessionStart skill-update drift nudge: a terse, throttled line telling the
|
|
2
|
+
// agent "your skills are stale, run `lorekit update`" — computed entirely
|
|
3
|
+
// offline (see ../shared/skill-versions.mjs) and gated by `updates.notify`
|
|
4
|
+
// (../shared/control.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Throttle state lives at `$LOREKIT_HOME/update-state.json`, following the
|
|
7
|
+
// exact precedent `telemetry/telemetry-identity.mjs` sets for a small,
|
|
8
|
+
// user-owned JSON file under the home tier: TOTAL reads (a missing/corrupt
|
|
9
|
+
// file degrades to `{}`, never throws), atomic writes via
|
|
10
|
+
// `shared/config.mjs`'s `writeFileAtomic`, and the file is written ONLY when
|
|
11
|
+
// there is something to record — never merely because this ran.
|
|
12
|
+
//
|
|
13
|
+
// The nudge fires at most once per NEW shipped-version signature, plus a
|
|
14
|
+
// 7-day cooldown on repeating that same signature. `updates.notify: off`
|
|
15
|
+
// short-circuits before any filesystem check runs, so an opted-out user's
|
|
16
|
+
// disk is never touched by this module at all.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import process from 'node:process';
|
|
20
|
+
import { homeRoot } from '../shared/control.mjs';
|
|
21
|
+
import { writeFileAtomic } from '../shared/config.mjs';
|
|
22
|
+
import { checkSkillVersions, skillsNeedingUpdate } from '../shared/skill-versions.mjs';
|
|
23
|
+
|
|
24
|
+
const STATE_FILE = 'update-state.json';
|
|
25
|
+
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
26
|
+
|
|
27
|
+
/** `$LOREKIT_HOME/update-state.json`, default `~/.lorekit/update-state.json`. */
|
|
28
|
+
export function updateStatePath(env = process.env) {
|
|
29
|
+
return path.join(homeRoot(env), STATE_FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the stored throttle state, or `{}`. TOTAL — a missing file, an
|
|
34
|
+
* unreadable one, a corrupt body, or a body that parses but isn't the right
|
|
35
|
+
* shape all yield `{}`, exactly like `telemetry-identity.mjs`'s `readIdentity`.
|
|
36
|
+
*/
|
|
37
|
+
export function readUpdateState(file = updateStatePath()) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
40
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
41
|
+
const out = {};
|
|
42
|
+
if (typeof parsed.lastNotifiedVersion === 'string' && parsed.lastNotifiedVersion) {
|
|
43
|
+
out.lastNotifiedVersion = parsed.lastNotifiedVersion;
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed.lastNotifiedAt === 'number' && Number.isFinite(parsed.lastNotifiedAt)) {
|
|
46
|
+
out.lastNotifiedAt = parsed.lastNotifiedAt;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Persist the throttle state, creating the home directory if needed. Returns
|
|
55
|
+
// true on success, false on any failure (unwritable home, full disk) — a
|
|
56
|
+
// write failure must not surface as an error, since a hook this fires from
|
|
57
|
+
// must never break the host on a filesystem hiccup.
|
|
58
|
+
function writeUpdateState(state, file = updateStatePath()) {
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
writeFileAtomic(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A stable signature naming exactly which skills need an update and at which
|
|
70
|
+
* shipped version, e.g. `"lorekit-memory@1.2.0,lorekit-setup@1.1.0"`. Sorted
|
|
71
|
+
* by skill name so the signature is deterministic regardless of scan order.
|
|
72
|
+
*
|
|
73
|
+
* This — not a bare boolean — is what the throttle compares against: a NEW
|
|
74
|
+
* release (a shipped version bump) or a previously-current skill drifting
|
|
75
|
+
* produces a DIFFERENT signature, which re-qualifies for a nudge even inside
|
|
76
|
+
* the 7-day cooldown on the old one. `""` means nothing needs an update.
|
|
77
|
+
*/
|
|
78
|
+
export function driftSignature(results) {
|
|
79
|
+
const bySkill = new Map();
|
|
80
|
+
for (const r of skillsNeedingUpdate(results)) {
|
|
81
|
+
if (!r.shipped || bySkill.has(r.name)) continue;
|
|
82
|
+
bySkill.set(r.name, r.shipped);
|
|
83
|
+
}
|
|
84
|
+
return [...bySkill.entries()]
|
|
85
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
86
|
+
.map(([name, shipped]) => `${name}@${shipped}`)
|
|
87
|
+
.join(',');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render the one-line nudge, or `null` when nothing needs an update. De-duped
|
|
92
|
+
* by skill name (the same `name -> shipped` Map `driftSignature` builds) —
|
|
93
|
+
* `skillsNeedingUpdate` returns one row per (skill, scope), and without the
|
|
94
|
+
* dedupe a skill outdated in BOTH the project and global install would count
|
|
95
|
+
* itself twice in the "(+N more)" tail. An `unknown` row (no readable
|
|
96
|
+
* installed version — the drift signature counts it too, see
|
|
97
|
+
* `driftSignature`) still gets named, just without a "vX →" on its left side,
|
|
98
|
+
* so the nudge is never silently dropped for the one state that most needs a
|
|
99
|
+
* "go look" — a legacy install with no parseable version at all.
|
|
100
|
+
*/
|
|
101
|
+
export function formatUpdateNudge(results) {
|
|
102
|
+
const bySkill = new Map();
|
|
103
|
+
for (const r of skillsNeedingUpdate(results)) {
|
|
104
|
+
if (!r.shipped || bySkill.has(r.name)) continue;
|
|
105
|
+
bySkill.set(r.name, r);
|
|
106
|
+
}
|
|
107
|
+
const needing = [...bySkill.values()];
|
|
108
|
+
if (needing.length === 0) return null;
|
|
109
|
+
const [first, ...rest] = needing;
|
|
110
|
+
const extra = rest.length > 0 ? ` (+${rest.length} more)` : '';
|
|
111
|
+
const headline = first.installed
|
|
112
|
+
? `${first.name} v${first.installed} → v${first.shipped}`
|
|
113
|
+
: `${first.name} → v${first.shipped}`;
|
|
114
|
+
return (
|
|
115
|
+
`LoreKit skills are outdated (${headline}${extra}). ` +
|
|
116
|
+
'Run `lorekit update` to refresh.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The full resolve: should a SessionStart nudge fire right now, and if so,
|
|
122
|
+
* record the throttle state and return the text? `null` means stay silent —
|
|
123
|
+
* `notify: off`, nothing outdated, or the same signature is still inside its
|
|
124
|
+
* 7-day cooldown.
|
|
125
|
+
*
|
|
126
|
+
* TOTAL and side-effect-light: nothing is read or written when `notify` is
|
|
127
|
+
* `off`, and the state file is written ONLY on the turn that actually emits a
|
|
128
|
+
* nudge — never merely because this ran. Any unexpected error (a throwing
|
|
129
|
+
* filesystem call the individual helpers didn't already swallow) is caught
|
|
130
|
+
* here too, so a caller never needs its own try/catch to stay safe.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveUpdateNudge(root, { notify = 'auto', now = Date.now(), env = process.env } = {}) {
|
|
133
|
+
if (notify === 'off') return null;
|
|
134
|
+
try {
|
|
135
|
+
const results = checkSkillVersions(root);
|
|
136
|
+
const signature = driftSignature(results);
|
|
137
|
+
if (!signature) return null;
|
|
138
|
+
|
|
139
|
+
const file = updateStatePath(env);
|
|
140
|
+
const state = readUpdateState(file);
|
|
141
|
+
const sameSignature = state.lastNotifiedVersion === signature;
|
|
142
|
+
const withinCooldown =
|
|
143
|
+
typeof state.lastNotifiedAt === 'number' && now - state.lastNotifiedAt < COOLDOWN_MS;
|
|
144
|
+
if (sameSignature && withinCooldown) return null;
|
|
145
|
+
|
|
146
|
+
const text = formatUpdateNudge(results);
|
|
147
|
+
if (!text) return null;
|
|
148
|
+
|
|
149
|
+
writeUpdateState({ lastNotifiedVersion: signature, lastNotifiedAt: now }, file);
|
|
150
|
+
return text;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -43,6 +43,7 @@ const FLAG = {
|
|
|
43
43
|
force: { desc: 'Overwrite / hard-delete' },
|
|
44
44
|
deep: { desc: 'Do a write→read→delete round-trip' },
|
|
45
45
|
telemetry: { desc: 'Verify the OTLP export credential works' },
|
|
46
|
+
check: { desc: 'Report drift without writing anything' },
|
|
46
47
|
json: { desc: 'Machine-readable output' },
|
|
47
48
|
scope: { desc: 'Restrict to / name a scope', arg: 'scope', complete: 'scope' },
|
|
48
49
|
key: { desc: 'Name the key explicitly', arg: 'key' },
|
|
@@ -107,6 +108,8 @@ const COMMANDS = [
|
|
|
107
108
|
flags: ['dir', 'project', 'global', 'yes'] },
|
|
108
109
|
{ name: 'doctor', summary: 'Verify the install, connectivity, token, scope',
|
|
109
110
|
flags: ['dir', 'mode', 'endpoint', 'token', 'store', 'deep', 'telemetry'] },
|
|
111
|
+
{ name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version',
|
|
112
|
+
flags: ['dir', 'project', 'global', 'check'] },
|
|
110
113
|
{ name: 'list', summary: 'List memories for the current directory', aliases: ['ls'],
|
|
111
114
|
flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
|
|
112
115
|
{ name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'],
|
package/src/shared/control.mjs
CHANGED
|
@@ -61,6 +61,21 @@ export function normalizeStopMode(v) {
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// `updates.notify` — whether the SessionStart hook may append a terse
|
|
65
|
+
// skill-update nudge when an installed skill has drifted behind the version
|
|
66
|
+
// this CLI ships. `auto` (default) | `off`. Same forgiving-vocabulary /
|
|
67
|
+
// repo-wins model as `hooks.stop`: a plain boolean, `false`/`none`/`disabled`
|
|
68
|
+
// all mean `off`, so a hand-edited JSON file reads naturally either way.
|
|
69
|
+
export const UPDATE_NOTIFY_MODES = ['auto', 'off'];
|
|
70
|
+
export function normalizeUpdateNotifyMode(v) {
|
|
71
|
+
if (typeof v === 'boolean') return v ? 'auto' : 'off';
|
|
72
|
+
if (typeof v !== 'string') return null;
|
|
73
|
+
const s = v.trim().toLowerCase();
|
|
74
|
+
if (['off', 'none', 'false', 'disabled', 'never', 'no'].includes(s)) return 'off';
|
|
75
|
+
if (['auto', 'on', 'true', 'enabled', 'always', 'yes'].includes(s)) return 'auto';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
// `hooks.userPrompt` — the per-turn relevance pull, on or off.
|
|
65
80
|
//
|
|
66
81
|
// A BOOLEAN, not a mode, and that is a deliberate limit on the surface. The
|
|
@@ -454,6 +469,12 @@ export function resolveControl({
|
|
|
454
469
|
normalizeUserPromptMode(userConfig['hooks.sessionStart.branchHint']) ||
|
|
455
470
|
'on';
|
|
456
471
|
|
|
472
|
+
// `updates.notify` — repo layer wins over user layer, default `auto`.
|
|
473
|
+
const updatesNotify =
|
|
474
|
+
normalizeUpdateNotifyMode(repoConfig['updates.notify']) ||
|
|
475
|
+
normalizeUpdateNotifyMode(userConfig['updates.notify']) ||
|
|
476
|
+
'auto';
|
|
477
|
+
|
|
457
478
|
// `hooks.adapter` — repo layer wins over user layer (explicit project override).
|
|
458
479
|
const hooksAdapter =
|
|
459
480
|
(typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
|
|
@@ -499,6 +520,7 @@ export function resolveControl({
|
|
|
499
520
|
hooksSessionStartBranchHint,
|
|
500
521
|
hooksAdapter,
|
|
501
522
|
hooksInstructions,
|
|
523
|
+
updatesNotify,
|
|
502
524
|
};
|
|
503
525
|
}
|
|
504
526
|
|
package/src/shared/flags.mjs
CHANGED
|
@@ -14,3 +14,76 @@ export function parseIntFlag(raw, name) {
|
|
|
14
14
|
}
|
|
15
15
|
return { value: Number(String(raw).trim()) };
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The eight dimension filters a retention policy / inline groom call can
|
|
20
|
+
* carry (migration 00093) — the CLI's flag-name ↔ field-name ↔ valid-mode
|
|
21
|
+
* table. Mirrors `groomDimensionFilterProperties` in
|
|
22
|
+
* `packages/schemas/src/shared/tool-catalog.ts` and `GROOM_DIMENSION_FIELDS`
|
|
23
|
+
* in `supabase/functions/mcp/tools.ts`; kept as ONE list here (not three) so
|
|
24
|
+
* `policy create`/`policy update`/`groom` cannot describe the dimensions
|
|
25
|
+
* differently.
|
|
26
|
+
*/
|
|
27
|
+
const DIMENSIONS = [
|
|
28
|
+
{ flag: 'tags', field: 'tags', modes: ['any', 'all', 'none'] },
|
|
29
|
+
{ flag: 'source-agent', field: 'source_agent', modes: ['in', 'nin'] },
|
|
30
|
+
{ flag: 'trigger', field: 'trigger', modes: ['in', 'nin'] },
|
|
31
|
+
{ flag: 'kind', field: 'kind', modes: ['in', 'nin'] },
|
|
32
|
+
{ flag: 'host', field: 'host', modes: ['in', 'nin'] },
|
|
33
|
+
{ flag: 'origin-repo', field: 'origin_repo', modes: ['in', 'nin'] },
|
|
34
|
+
{ flag: 'origin-branch', field: 'origin_branch', modes: ['in', 'nin'] },
|
|
35
|
+
{ flag: 'origin-pr', field: 'origin_pr', modes: ['in', 'nin'] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the eight retention dimension filters (`--tags`/`--kind`/`--host`/
|
|
40
|
+
* `--trigger`/`--source-agent`/`--origin-repo`/`--origin-branch`/`--origin-pr`,
|
|
41
|
+
* each with a `--<dim>-mode`) out of a parsed-args object.
|
|
42
|
+
*
|
|
43
|
+
* Comma-separated values — matching the existing `write --tags a,b,c`
|
|
44
|
+
* convention. The minimal flag parser (`bin/lorekit.mjs`'s `parseArgs`) is
|
|
45
|
+
* last-wins on a repeated flag, so comma-splitting is the established
|
|
46
|
+
* multi-value shape in this CLI, not a new one invented here.
|
|
47
|
+
*
|
|
48
|
+
* `--<dim>-mode` is validated against that DIMENSION's own enum (`tags-mode`
|
|
49
|
+
* accepts any/all/none; every other dimension accepts in/nin) — an invalid
|
|
50
|
+
* value is rejected rather than silently forwarded to the RPC as a raw
|
|
51
|
+
* Postgres enum error.
|
|
52
|
+
*
|
|
53
|
+
* With `clearable: true` (policy update), `--clear-<dim>` sends an explicit
|
|
54
|
+
* `null` for the VALUE field only, and skips both the value and mode flags for
|
|
55
|
+
* that dimension entirely — matching how `policy update --clear-min-age-days`
|
|
56
|
+
* already ignores `--min-age-days` when both are passed. The MODE field is
|
|
57
|
+
* left untouched by a clear: it is meaningless once the value is null, and
|
|
58
|
+
* silently dropping it if the caller separately set it would be a second
|
|
59
|
+
* surprise on top of the clear.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ conditions }` containing only the fields the caller actually
|
|
62
|
+
* named — never a full 16-key object of undefineds, so a caller can spread it
|
|
63
|
+
* straight into a request body without a second `stripUndefined` pass losing
|
|
64
|
+
* an intentional `null` — or `{ error }` naming the offending flag.
|
|
65
|
+
*/
|
|
66
|
+
export function parseDimensionConditions(args, { clearable = false } = {}) {
|
|
67
|
+
const conditions = {};
|
|
68
|
+
for (const { flag, field, modes } of DIMENSIONS) {
|
|
69
|
+
if (clearable && args[`clear-${flag}`]) {
|
|
70
|
+
conditions[field] = null;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const raw = args[flag];
|
|
74
|
+
if (raw !== undefined) {
|
|
75
|
+
conditions[field] = String(raw)
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((s) => s.trim())
|
|
78
|
+
.filter((s) => s.length > 0);
|
|
79
|
+
}
|
|
80
|
+
const modeRaw = args[`${flag}-mode`];
|
|
81
|
+
if (modeRaw !== undefined) {
|
|
82
|
+
if (!modes.includes(modeRaw)) {
|
|
83
|
+
return { error: `--${flag}-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` };
|
|
84
|
+
}
|
|
85
|
+
conditions[`${field}_mode`] = modeRaw;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { conditions };
|
|
89
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Offline "installed vs shipped" skill version check.
|
|
2
|
+
//
|
|
3
|
+
// `lorekit install` copies each skill's SKILL.md verbatim and stamps no version
|
|
4
|
+
// anywhere else, so a skill installed at an old version has reported a healthy
|
|
5
|
+
// `doctor` PASS forever with no signal to update. The version data already
|
|
6
|
+
// exists: every skill's `SKILL.md` frontmatter carries `metadata.version`
|
|
7
|
+
// (e.g. `'1.0.0'`), and the shipped skill source travels in the SAME npm
|
|
8
|
+
// tarball as the running CLI (`SKILLS[].source` in `./config.mjs`) — so
|
|
9
|
+
// comparing "what's on disk" against "what this CLI ships" needs zero network
|
|
10
|
+
// calls and no server round-trip. `doctor` (per-scope reporting) and
|
|
11
|
+
// `lorekit update` (the refresh command) and the SessionStart drift nudge
|
|
12
|
+
// (`../core/update-notify.mjs`) all read through this one module so the three
|
|
13
|
+
// surfaces can't disagree about what counts as outdated.
|
|
14
|
+
//
|
|
15
|
+
// Dependency-free `.mjs`, like the other `shared/*.mjs` pure modules — no npm
|
|
16
|
+
// imports beyond node builtins.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { SKILLS, skillInstallDir } from './config.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pull the YAML frontmatter block out of a SKILL.md body — the text between
|
|
23
|
+
* the first `---` line and the next one. `null` when there is no such block
|
|
24
|
+
* (not a SKILL.md-shaped file, or a corrupt/truncated one).
|
|
25
|
+
*/
|
|
26
|
+
export function extractFrontmatter(markdown) {
|
|
27
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(typeof markdown === 'string' ? markdown : '');
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse `metadata.version` out of a SKILL.md's frontmatter.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately minimal: every SKILL.md this CLI ships has exactly one
|
|
35
|
+
* `version:` line (nested under `metadata:`), so a plain line match — robust
|
|
36
|
+
* to single/double quotes and a trailing comment — is enough without pulling
|
|
37
|
+
* in a YAML parser this zero-dep package does not otherwise need. Returns
|
|
38
|
+
* `null` when the frontmatter is absent or carries no `version:` line at all
|
|
39
|
+
* (a legacy skill file predating the version stamp).
|
|
40
|
+
*/
|
|
41
|
+
export function parseSkillVersion(markdown) {
|
|
42
|
+
const frontmatter = extractFrontmatter(markdown);
|
|
43
|
+
if (!frontmatter) return null;
|
|
44
|
+
const m = /^[ \t]*version:[ \t]*(['"]?)([^'"\n#]+?)\1[ \t]*(?:#.*)?$/m.exec(frontmatter);
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
const version = m[2].trim();
|
|
47
|
+
return version || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read + parse a SKILL.md's version, or `null` for anything that isn't a
|
|
51
|
+
// readable, parseable file — an absent path, a permissions error, and a file
|
|
52
|
+
// with no `version:` line all collapse to the same "unknown" signal.
|
|
53
|
+
function readVersion(skillMdPath) {
|
|
54
|
+
try {
|
|
55
|
+
return parseSkillVersion(fs.readFileSync(skillMdPath, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Dependency-free semver-ish compare of two dotted-numeric version strings.
|
|
63
|
+
*
|
|
64
|
+
* Returns `-1` / `0` / `1` the usual way, or `null` when either input is not a
|
|
65
|
+
* usable version string (missing, empty, or containing a non-numeric segment —
|
|
66
|
+
* pre-release/build metadata like `1.0.0-beta` is intentionally out of scope,
|
|
67
|
+
* since every shipped SKILL.md version is a plain `x.y.z`). `null` reads as
|
|
68
|
+
* "cannot compare", which callers treat as `unknown` rather than guessing a
|
|
69
|
+
* direction.
|
|
70
|
+
*/
|
|
71
|
+
export function compareVersions(a, b) {
|
|
72
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !a.trim() || !b.trim()) return null;
|
|
73
|
+
const toParts = (v) => v.trim().split('.').map((seg) => Number(seg));
|
|
74
|
+
const pa = toParts(a);
|
|
75
|
+
const pb = toParts(b);
|
|
76
|
+
if (pa.some((n) => !Number.isFinite(n)) || pb.some((n) => !Number.isFinite(n))) return null;
|
|
77
|
+
const len = Math.max(pa.length, pb.length);
|
|
78
|
+
for (let i = 0; i < len; i++) {
|
|
79
|
+
const x = pa[i] ?? 0;
|
|
80
|
+
const y = pb[i] ?? 0;
|
|
81
|
+
if (x < y) return -1;
|
|
82
|
+
if (x > y) return 1;
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The scopes a skill can be installed in — matches `skillInstallDir`'s. */
|
|
88
|
+
export const SKILL_SCOPES = ['project', 'global'];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Classify one (skill, scope) pair against the version this CLI ships.
|
|
92
|
+
*
|
|
93
|
+
* `state`:
|
|
94
|
+
* - `not-installed` — no SKILL.md at that scope.
|
|
95
|
+
* - `current` — installed version >= shipped version.
|
|
96
|
+
* - `outdated` — installed version < shipped version.
|
|
97
|
+
* - `unknown` — a SKILL.md exists but a version could not be
|
|
98
|
+
* resolved on one side (an unparseable/legacy
|
|
99
|
+
* installed file, or — in practice never — an
|
|
100
|
+
* unparseable shipped one), so no direction can be
|
|
101
|
+
* asserted.
|
|
102
|
+
*/
|
|
103
|
+
function classify(installedPath, shipped) {
|
|
104
|
+
const installedExists = fs.existsSync(installedPath);
|
|
105
|
+
if (!installedExists) {
|
|
106
|
+
return { installed: null, state: 'not-installed' };
|
|
107
|
+
}
|
|
108
|
+
const installed = readVersion(installedPath);
|
|
109
|
+
if (installed == null || shipped == null) {
|
|
110
|
+
return { installed, state: 'unknown' };
|
|
111
|
+
}
|
|
112
|
+
const cmp = compareVersions(installed, shipped);
|
|
113
|
+
if (cmp === null) return { installed, state: 'unknown' };
|
|
114
|
+
return { installed, state: cmp < 0 ? 'outdated' : 'current' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check every skill in `skills` (default: the CLI's own `SKILLS` list) at
|
|
119
|
+
* every scope, comparing the INSTALLED `SKILL.md`'s `metadata.version`
|
|
120
|
+
* against the SHIPPED source's. Pure filesystem reads — no network, no store.
|
|
121
|
+
*
|
|
122
|
+
* Returns one row per (skill, scope):
|
|
123
|
+
* `{ name, scope, installedPath, installed, shipped, state }`
|
|
124
|
+
*/
|
|
125
|
+
export function checkSkillVersions(root, { skills = SKILLS } = {}) {
|
|
126
|
+
const results = [];
|
|
127
|
+
for (const skill of skills) {
|
|
128
|
+
const shipped = readVersion(path.join(skill.source, 'SKILL.md'));
|
|
129
|
+
for (const scope of SKILL_SCOPES) {
|
|
130
|
+
const installedPath = path.join(skillInstallDir(root, scope, skill.name), 'SKILL.md');
|
|
131
|
+
const { installed, state } = classify(installedPath, shipped);
|
|
132
|
+
results.push({ name: skill.name, scope, installedPath, installed, shipped, state });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The subset of `checkSkillVersions` results that need an update — `outdated`
|
|
140
|
+
* or `unknown` (a legacy install with no readable version is exactly the case
|
|
141
|
+
* `lorekit update` exists to repair, since re-copying stamps a fresh one).
|
|
142
|
+
* `not-installed` and `current` are excluded — neither one is drift to act on.
|
|
143
|
+
*/
|
|
144
|
+
export function skillsNeedingUpdate(results) {
|
|
145
|
+
return (Array.isArray(results) ? results : []).filter(
|
|
146
|
+
(r) => r && (r.state === 'outdated' || r.state === 'unknown'),
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/store/remote.mjs
CHANGED
|
@@ -42,6 +42,20 @@ function stripUndefined(obj) {
|
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Drop only `undefined` — keeps an explicit `null`. Bare `stripUndefined`
|
|
46
|
+
// above is right for a CREATE body, where an omitted dimension and an
|
|
47
|
+
// explicit `null` mean the same "not filtered". It is wrong for a PATCH,
|
|
48
|
+
// where `null` is the caller's explicit instruction to CLEAR a
|
|
49
|
+
// previously-set condition (`policy update --clear-kind`) — dropping it
|
|
50
|
+
// there means the field silently stays whatever it already was, which is
|
|
51
|
+
// the opposite of what was asked. Named for what it keeps, not what it
|
|
52
|
+
// drops, since "strip" alone reads as "strip everything falsy-ish".
|
|
53
|
+
function stripUndefinedKeepNull(obj) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [k, v] of Object.entries(obj || {})) if (v !== undefined) out[k] = v;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* A read that could not be answered — a transport failure or a non-2xx status,
|
|
47
61
|
* as opposed to "the lesson is not there".
|
|
@@ -768,9 +782,20 @@ class RemoteStore {
|
|
|
768
782
|
return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
|
|
769
783
|
}
|
|
770
784
|
|
|
771
|
-
// POST /policies → the created policy object.
|
|
772
|
-
|
|
773
|
-
|
|
785
|
+
// POST /policies → the created policy object. `...dims` carries whichever
|
|
786
|
+
// of the eight dimension filters (+ their `_mode` companions) the caller
|
|
787
|
+
// named — see `parseDimensionConditions` in `shared/flags.mjs`, the shared
|
|
788
|
+
// parser policy create/update and groom all go through.
|
|
789
|
+
async policyCreate({
|
|
790
|
+
scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
791
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
792
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
793
|
+
} = {}) {
|
|
794
|
+
const body = stripUndefined({
|
|
795
|
+
scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
796
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
797
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
798
|
+
});
|
|
774
799
|
const res = await this._rest('/memories/policies', { method: 'POST', body });
|
|
775
800
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
776
801
|
return { ok: true, policy: res.data };
|
|
@@ -778,10 +803,11 @@ class RemoteStore {
|
|
|
778
803
|
|
|
779
804
|
// PATCH /policies/:id → the updated policy object. An omitted field is left
|
|
780
805
|
// unchanged; pass an explicit `null` in the patch to clear a condition —
|
|
781
|
-
// this method does not strip nulls, only `undefined` (
|
|
782
|
-
// that distinction, which is the whole point of the RPC's JSONB-patch
|
|
806
|
+
// this method does not strip nulls, only `undefined` (stripUndefinedKeepNull
|
|
807
|
+
// keeps that distinction, which is the whole point of the RPC's JSONB-patch
|
|
808
|
+
// design and of `policy update --clear-*`).
|
|
783
809
|
async policyUpdate(id, patch = {}) {
|
|
784
|
-
const body =
|
|
810
|
+
const body = stripUndefinedKeepNull(patch);
|
|
785
811
|
const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
786
812
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
787
813
|
return { ok: true, policy: res.data };
|
|
@@ -796,9 +822,18 @@ class RemoteStore {
|
|
|
796
822
|
|
|
797
823
|
// POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
|
|
798
824
|
// candidates a groom() run would archive. Pass either `policy_id` or
|
|
799
|
-
// `scope` (+ optional conditions
|
|
800
|
-
|
|
801
|
-
|
|
825
|
+
// `scope` (+ optional conditions, including the eight dimension filters —
|
|
826
|
+
// see policyCreate's comment), never both.
|
|
827
|
+
async groomPreview({
|
|
828
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
829
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
830
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
831
|
+
} = {}) {
|
|
832
|
+
const body = stripUndefined({
|
|
833
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
834
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
835
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
836
|
+
});
|
|
802
837
|
const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
|
|
803
838
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
804
839
|
return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
@@ -806,8 +841,16 @@ class RemoteStore {
|
|
|
806
841
|
|
|
807
842
|
// POST /groom/run → archives every previewed candidate, in one transaction.
|
|
808
843
|
// Soft-archive only (recoverable via restore); never hard-deletes.
|
|
809
|
-
async groomRun({
|
|
810
|
-
|
|
844
|
+
async groomRun({
|
|
845
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
846
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
847
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
848
|
+
} = {}) {
|
|
849
|
+
const body = stripUndefined({
|
|
850
|
+
policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
|
|
851
|
+
tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
|
|
852
|
+
origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
|
|
853
|
+
});
|
|
811
854
|
const res = await this._rest('/memories/groom/run', { method: 'POST', body });
|
|
812
855
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
813
856
|
return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|