@lorekit/cli 1.70.0 → 1.71.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 +24 -2
- package/bin/lorekit.mjs +36 -1
- 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/hook.mjs +22 -5
- 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/skill-versions.mjs +148 -0
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`
|
|
@@ -1110,8 +1131,8 @@ also returns their headroom against the plan's memory cap.
|
|
|
1110
1131
|
| Flag | Meaning |
|
|
1111
1132
|
|------|---------|
|
|
1112
1133
|
| `-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`) |
|
|
1134
|
+
| `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default). Narrows to the project scope (`update`) |
|
|
1135
|
+
| `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`). Narrows to the global scope (`update`) |
|
|
1115
1136
|
| `-e, --endpoint <url>` | LoreKit MCP endpoint |
|
|
1116
1137
|
| `-t, --token <token>` | LoreKit token |
|
|
1117
1138
|
| `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
|
|
@@ -1125,6 +1146,7 @@ also returns their headroom against the plan's memory cap.
|
|
|
1125
1146
|
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
1126
1147
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
1127
1148
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
1149
|
+
| `--check` | Dry run: report skill-install drift without writing anything (`update`) |
|
|
1128
1150
|
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `invariants candidates` / `link` / `purge` / `purge-expired`) |
|
|
1129
1151
|
| `--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
1152
|
| `--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
|
|
|
@@ -1090,6 +1123,8 @@ const KNOWN_FLAGS = [
|
|
|
1090
1123
|
'clear-max-opened-count', 'off',
|
|
1091
1124
|
// `obligations`
|
|
1092
1125
|
'files', 'strict', 'strict-all',
|
|
1126
|
+
// `update`
|
|
1127
|
+
'check',
|
|
1093
1128
|
];
|
|
1094
1129
|
|
|
1095
1130
|
async function main() {
|
|
@@ -1102,7 +1137,7 @@ async function main() {
|
|
|
1102
1137
|
const argv = process.argv.slice(2);
|
|
1103
1138
|
const args = parseArgs(argv, {
|
|
1104
1139
|
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'],
|
|
1140
|
+
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', 'check'],
|
|
1106
1141
|
known: KNOWN_FLAGS,
|
|
1107
1142
|
});
|
|
1108
1143
|
|
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/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
|
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|