@lorekit/cli 1.69.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-groom/rules/grooming-pass.md +34 -0
- package/skill/lorekit-memory/SKILL.md +1 -1
- package/skill/lorekit-setup/SKILL.md +141 -104
- package/skill/lorekit-setup/rules/cold-start-seeding.md +102 -0
- package/skill/lorekit-setup/rules/loop-health.md +117 -0
- package/skill/lorekit-setup/rules/proving-improvement.md +111 -0
- package/skill/lorekit-setup/rules/self-improvement-loops.md +52 -4
- package/skill/lorekit-setup/rules/team-and-portfolio.md +107 -0
- package/skill/lorekit-setup/templates/README.md +26 -0
- package/skill/lorekit-setup/templates/ci-job.md +90 -0
- package/skill/lorekit-setup/templates/code-changing-agent.md +89 -0
- package/skill/lorekit-setup/templates/multi-step-orchestrator.md +76 -0
- package/skill/lorekit-setup/templates/reviewer-reconcile-host.md +86 -0
- package/src/commands/doctor.mjs +41 -11
- package/src/commands/hook.mjs +22 -5
- package/src/commands/lint.mjs +4 -2
- 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/lessons-view.mjs +71 -0
- package/src/shared/skill-versions.mjs +148 -0
|
@@ -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
|
|
|
@@ -253,6 +253,36 @@ export function diffGroups(offline = {}, remote = {}) {
|
|
|
253
253
|
// too terse to carry a durable observation (e.g. "yes", "fixed", "todo").
|
|
254
254
|
export const MIN_VALUE_LEN = 12;
|
|
255
255
|
|
|
256
|
+
// Blank the INTERIOR of fenced code blocks (``` or ~~~ fences) so example content
|
|
257
|
+
// — a lesson documenting an `<!-- MARKER -->` or pasting a ```yaml front-matter
|
|
258
|
+
// sample — is never mistaken for hidden metadata. Such content renders as VISIBLE
|
|
259
|
+
// fenced text, the opposite of the digest-hidden block `hidden-metadata` targets,
|
|
260
|
+
// and `lint` is a CI gate, so a false positive there fails a legitimate lesson.
|
|
261
|
+
// Line positions are preserved (interiors and fence lines become empty) so the
|
|
262
|
+
// heading-anchored checks still see the real document structure. A closing fence
|
|
263
|
+
// is ≥3 of the SAME character as the opener with no trailing info string
|
|
264
|
+
// (CommonMark); an opener may carry an info string (```yaml). Pure.
|
|
265
|
+
function stripFencedCode(value) {
|
|
266
|
+
let fence = null; // the active fence character (` or ~), or null when outside a block
|
|
267
|
+
return String(value)
|
|
268
|
+
.split('\n')
|
|
269
|
+
.map((line) => {
|
|
270
|
+
const m = line.match(/^\s*(`{3,}|~{3,})/);
|
|
271
|
+
if (fence === null) {
|
|
272
|
+
if (m) {
|
|
273
|
+
fence = m[1][0];
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
return line;
|
|
277
|
+
}
|
|
278
|
+
if (m && m[1][0] === fence && line.trim().replace(/[`~\s]/g, '') === '') {
|
|
279
|
+
fence = null;
|
|
280
|
+
}
|
|
281
|
+
return '';
|
|
282
|
+
})
|
|
283
|
+
.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
256
286
|
// The lint rule set: each a pure predicate over a normalized entry returning a
|
|
257
287
|
// short reason string when it FIRES, or null when the entry is clean. Kept as
|
|
258
288
|
// discrete named functions so each rule is independently unit-testable and the
|
|
@@ -335,6 +365,47 @@ export const LINT_RULES = {
|
|
|
335
365
|
if (parsed === null || typeof parsed !== 'object') return null; // a bare scalar — short-value's to catch when short, otherwise unjudged.
|
|
336
366
|
return 'value is a JSON object/array with no kind set — it renders as a raw JSON blob in every SessionStart digest; set --kind bus or --kind signal';
|
|
337
367
|
},
|
|
368
|
+
// A lesson body is markdown for humans — no HTML comment, no front-matter, no
|
|
369
|
+
// `key=value` header. The `<!-- meta: seen_count=… status=… trigger-context=… -->`
|
|
370
|
+
// block is a REPUDIATED legacy convention this repo's lorekit-setup skill once
|
|
371
|
+
// prescribed (still parsed as a read-fallback in `candidates-pure.mjs` /
|
|
372
|
+
// `commands/invariants.mjs`, and skipped by the digest preview in `core/lessons.mjs`).
|
|
373
|
+
// It is wrong on the merits: an HTML comment renders to nothing, so a human sees a
|
|
374
|
+
// lesson starting mid-sentence, while a baked-in `seen_count`/`status`/`trigger`
|
|
375
|
+
// silently disagrees with the store's own column/tag/field. Conservative like the
|
|
376
|
+
// other rules — three concrete shapes only, never a fuzzy "looks like metadata":
|
|
377
|
+
// 1. any HTML comment (`<!--`), the strongest and most common offender;
|
|
378
|
+
// 2. a body that OPENS with a YAML front-matter block (`---` as the first line);
|
|
379
|
+
// 3. a machine-metadata header (`meta:`, `seen_count`, `status=`, `expires`,
|
|
380
|
+
// `ttl(_days)`, `trigger-context`) appearing BEFORE the first `#` title, so a
|
|
381
|
+
// legitimate prose line mid-body never trips it.
|
|
382
|
+
// All three run against a fence-stripped copy (`stripFencedCode`) so a lesson that
|
|
383
|
+
// DOCUMENTS one of these shapes inside a code block is never flagged.
|
|
384
|
+
'hidden-metadata': (e) => {
|
|
385
|
+
const raw = String(e.value ?? '');
|
|
386
|
+
if (!raw.trim()) return null; // an empty value is `empty-value`'s to report.
|
|
387
|
+
const v = stripFencedCode(raw);
|
|
388
|
+
if (!v.trim()) return null; // nothing outside code fences — no prose metadata to flag.
|
|
389
|
+
if (v.includes('<!--')) {
|
|
390
|
+
return "value contains an HTML comment — lesson bodies are pure markdown; move seen_count → the column, status → a status:: tag, trigger → the trigger field";
|
|
391
|
+
}
|
|
392
|
+
const lines = v.split('\n');
|
|
393
|
+
const firstNonEmpty = lines.find((l) => l.trim() !== '');
|
|
394
|
+
if (firstNonEmpty !== undefined && firstNonEmpty.trim() === '---') {
|
|
395
|
+
return 'value opens with a front-matter block — lesson bodies carry no front-matter; every stored fact belongs in its own write field';
|
|
396
|
+
}
|
|
397
|
+
const headingIdx = lines.findIndex((l) => /^\s*#/.test(l));
|
|
398
|
+
if (headingIdx > 0) {
|
|
399
|
+
const metaHeader = lines
|
|
400
|
+
.slice(0, headingIdx)
|
|
401
|
+
.filter((l) => l.trim() !== '')
|
|
402
|
+
.find((l) => /^\s*(meta\b|seen_count\b|status\s*=|expires\b|ttl(?:_days)?\b|trigger[-_]context\b)/i.test(l));
|
|
403
|
+
if (metaHeader) {
|
|
404
|
+
return `value has a machine-metadata header before the title ('${metaHeader.trim().slice(0, 40)}') — move it to the store's own fields`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
},
|
|
338
409
|
};
|
|
339
410
|
|
|
340
411
|
// Run every lint rule against one normalized entry, returning the findings it
|
|
@@ -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
|
+
}
|