skillrepo 4.8.4 → 4.9.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 +3 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +32 -7
- package/src/commands/update.mjs +29 -3
- package/src/lib/file-write.mjs +39 -0
- package/src/lib/sync.mjs +317 -26
- package/src/test/commands/init.test.mjs +90 -0
- package/src/test/commands/update.test.mjs +166 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +15 -11
- package/src/test/lib/file-write.test.mjs +35 -0
- package/src/test/lib/sync.test.mjs +665 -11
package/README.md
CHANGED
|
@@ -370,7 +370,7 @@ By default `skillrepo init` prompts you to install this hook. If you said no (or
|
|
|
370
370
|
|
|
371
371
|
#### Auto-refresh hooks for other agents
|
|
372
372
|
|
|
373
|
-
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library
|
|
373
|
+
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library stays current across your sessions without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
|
|
374
374
|
|
|
375
375
|
| Agent | Hook config path | Notes |
|
|
376
376
|
|-------|------------------|-------|
|
|
@@ -393,6 +393,8 @@ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents
|
|
|
393
393
|
|
|
394
394
|
**On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
|
|
395
395
|
|
|
396
|
+
**Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled (it always queries the server live), though it doesn't reset the window.
|
|
397
|
+
|
|
396
398
|
Flags:
|
|
397
399
|
|
|
398
400
|
- `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
|
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -605,8 +605,17 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
605
605
|
// placementTargetsFor (no vendors specified), and even if it
|
|
606
606
|
// didn't, fetching skill files we have nowhere to write is
|
|
607
607
|
// pure waste.
|
|
608
|
-
|
|
609
|
-
|
|
608
|
+
//
|
|
609
|
+
// `--global` does NOT change that. `effectiveVendors` returns the
|
|
610
|
+
// `--agent none` sentinel verbatim under --global precisely because
|
|
611
|
+
// "--global --agent none" still means "no placement writes" (see its
|
|
612
|
+
// test in cli-config.test.mjs), and this guard has to honour the same
|
|
613
|
+
// contract. It previously carried `&& !flags.global`, which defeated
|
|
614
|
+
// the skip for exactly the combination the comment above says would
|
|
615
|
+
// throw: every skill in the library was quarantined one-by-one with a
|
|
616
|
+
// misleading "will be retried next session" warning, and init then
|
|
617
|
+
// printed "No skills in library yet" and "SkillRepo is ready" (#2433).
|
|
618
|
+
const skipFirstSync = Array.isArray(vendors) && vendors.length === 0;
|
|
610
619
|
if (skipFirstSync) {
|
|
611
620
|
p.success("Skipped first sync (--agent none).");
|
|
612
621
|
syncSummary = {
|
|
@@ -681,13 +690,29 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
681
690
|
}
|
|
682
691
|
}
|
|
683
692
|
|
|
693
|
+
// `skipped` counts here: a run that dropped a skill is NOT a
|
|
694
|
+
// zero-delta run, and reporting "No skills in library yet" or
|
|
695
|
+
// "up to date" for it is a false statement to the user. Same defect
|
|
696
|
+
// class as the printSummary/hook fix in update.mjs (#2433).
|
|
684
697
|
const zeroDeltas =
|
|
685
|
-
syncSummary.added +
|
|
698
|
+
syncSummary.added +
|
|
699
|
+
syncSummary.updated +
|
|
700
|
+
syncSummary.removed +
|
|
701
|
+
(syncSummary.skipped ?? 0) ===
|
|
702
|
+
0;
|
|
686
703
|
|
|
687
|
-
if (syncFailedReason) {
|
|
688
|
-
// The warning
|
|
689
|
-
// would be misleading, so we skip
|
|
690
|
-
// is in the final `SkillRepo is
|
|
704
|
+
if (syncFailedReason || skipFirstSync) {
|
|
705
|
+
// The warning (or the "Skipped first sync" line) already printed;
|
|
706
|
+
// the step-summary success line would be misleading, so we skip
|
|
707
|
+
// it. Any helpful "next steps" is in the final `SkillRepo is
|
|
708
|
+
// ready` block.
|
|
709
|
+
//
|
|
710
|
+
// `skipFirstSync` belongs here for the same reason the synthesized
|
|
711
|
+
// summary above uses `fullSync: null`: the network call never ran,
|
|
712
|
+
// so we do not know the library's state. Falling through printed
|
|
713
|
+
// "Library is up to date (no changes since last sync)" — a
|
|
714
|
+
// confident claim about a server we never contacted, on the same
|
|
715
|
+
// screen as "Skipped first sync" (#2433).
|
|
691
716
|
} else if (syncSummary.notModified) {
|
|
692
717
|
// 304 Not Modified — the client had the current ETag already.
|
|
693
718
|
// Definitively "up to date" regardless of whether the library
|
package/src/commands/update.mjs
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* full contract. When this flag is absent, the
|
|
21
21
|
* command behaves as before (exit non-zero on
|
|
22
22
|
* network / auth / disk failures).
|
|
23
|
+
* Throttled (#2174): within MIN_SYNC_INTERVAL_MS of the
|
|
24
|
+
* last sync attempt this makes ZERO network calls (the
|
|
25
|
+
* hook fires every session; a bare `update` is never
|
|
26
|
+
* throttled).
|
|
23
27
|
*
|
|
24
28
|
* v4.1.0 silent mode (#1240):
|
|
25
29
|
* --silent Suppress stdout: write `{}` on success, propagate
|
|
@@ -144,9 +148,20 @@ export async function runUpdate(argv, io = {}) {
|
|
|
144
148
|
apiKey: flags.apiKey,
|
|
145
149
|
vendors,
|
|
146
150
|
global: flags.global,
|
|
151
|
+
// Throttle SessionStart syncs (#2174): this hook fires on every
|
|
152
|
+
// Claude Code session, so within MIN_SYNC_INTERVAL_MS of the last
|
|
153
|
+
// attempt runSync short-circuits with zero network calls. ONLY hook
|
|
154
|
+
// invocations pass this — a bare `skillrepo update` always syncs.
|
|
155
|
+
throttle: true,
|
|
147
156
|
io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
|
|
148
157
|
});
|
|
149
|
-
|
|
158
|
+
// `skipped` is included so the hook cannot go silent on a dropped
|
|
159
|
+
// skill: this path black-holes BOTH streams, so if the count that
|
|
160
|
+
// gates output ignores skips, a quarantined skill produces no output
|
|
161
|
+
// anywhere and exits 0 (#2413 adversarial review).
|
|
162
|
+
const skipped = summary.skipped ?? 0;
|
|
163
|
+
const total =
|
|
164
|
+
summary.added + summary.updated + summary.removed + skipped;
|
|
150
165
|
if (summary.notModified || total === 0) {
|
|
151
166
|
// 304 Not Modified OR 200 with zero deltas — silent by
|
|
152
167
|
// contract. Users should not see "Syncing..." on every
|
|
@@ -154,7 +169,9 @@ export async function runUpdate(argv, io = {}) {
|
|
|
154
169
|
return;
|
|
155
170
|
}
|
|
156
171
|
stdout.write(
|
|
157
|
-
`[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed
|
|
172
|
+
`[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
|
|
173
|
+
(skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
|
|
174
|
+
`.\n`,
|
|
158
175
|
);
|
|
159
176
|
} catch (err) {
|
|
160
177
|
// The one-line failure message is the user's primary signal
|
|
@@ -218,6 +235,10 @@ export async function runUpdate(argv, io = {}) {
|
|
|
218
235
|
apiKey: flags.apiKey,
|
|
219
236
|
vendors,
|
|
220
237
|
global: flags.global,
|
|
238
|
+
// Throttle the cohort SessionStart hook too (#2174) — same reasoning
|
|
239
|
+
// as --session-hook: it fires every session, so skip the network
|
|
240
|
+
// within MIN_SYNC_INTERVAL_MS of the last attempt.
|
|
241
|
+
throttle: true,
|
|
221
242
|
// sync.mjs surfaces non-fatal warnings (e.g. failed to persist
|
|
222
243
|
// last-sync state) via stderr; preserve that channel so a real
|
|
223
244
|
// operator running `update --silent` from a terminal can still
|
|
@@ -253,7 +274,11 @@ export async function runUpdate(argv, io = {}) {
|
|
|
253
274
|
}
|
|
254
275
|
|
|
255
276
|
function printSummary(s, out) {
|
|
256
|
-
|
|
277
|
+
// `skipped` counts here: a run that dropped a skill is NOT "up to date",
|
|
278
|
+
// and saying so was a false statement to the user (#2413 adversarial
|
|
279
|
+
// review — the counter was incremented and never read).
|
|
280
|
+
const skipped = s.skipped ?? 0;
|
|
281
|
+
const total = s.added + s.updated + s.removed + skipped;
|
|
257
282
|
if (s.notModified || total === 0) {
|
|
258
283
|
out.write(" ✓ Library is up to date.\n");
|
|
259
284
|
return;
|
|
@@ -262,6 +287,7 @@ function printSummary(s, out) {
|
|
|
262
287
|
if (s.added > 0) out.write(` + ${s.added} added\n`);
|
|
263
288
|
if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
|
|
264
289
|
if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
|
|
290
|
+
if (skipped > 0) out.write(` ! ${skipped} skipped (see warnings above)\n`);
|
|
265
291
|
out.write("\n");
|
|
266
292
|
}
|
|
267
293
|
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* next `update` run fully overwrites it.
|
|
28
28
|
*
|
|
29
29
|
* • Safety checks (NOT layout enforcement):
|
|
30
|
+
* - Control characters (C0/DEL/C1, raw or URL-encoded) — rejected
|
|
31
|
+
* (matches server CONTROL_CHARS, #2402)
|
|
30
32
|
* - Path traversal (..) — rejected
|
|
31
33
|
* - Absolute paths — rejected
|
|
32
34
|
* - Depth > 5 — rejected (matches server MAX_PATH_DEPTH)
|
|
@@ -131,6 +133,31 @@ const GLOBAL_TARGETS = Object.freeze([
|
|
|
131
133
|
|
|
132
134
|
// ── Public API ──────────────────────────────────────────────────────────
|
|
133
135
|
|
|
136
|
+
// Unicode Cc: C0 controls, DEL, and C1 controls. Same rule as the
|
|
137
|
+
// server-side validator (#2402): a control character in a path can inject
|
|
138
|
+
// line breaks or terminal escapes into prompts, logs, and rendered file
|
|
139
|
+
// trees — and the CLI writes these paths to the user's disk on sync.
|
|
140
|
+
// Mirrors CONTROL_CHAR_CLASS in src/lib/skills/file-validation.ts (the
|
|
141
|
+
// CLI cannot import server TS). Always extend via \uXXXX escapes — NEVER
|
|
142
|
+
// paste a raw control byte; a raw byte silently corrupts the class.
|
|
143
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
144
|
+
const CONTROL_CHARS_GLOBAL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render a path for an error message with control characters
|
|
148
|
+
* \uXXXX-escaped, so the error string can never re-inject the characters
|
|
149
|
+
* it rejects into terminal output or sync logs.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} value
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function escapeControlChars(value) {
|
|
155
|
+
return value.replace(
|
|
156
|
+
CONTROL_CHARS_GLOBAL,
|
|
157
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
/**
|
|
135
162
|
* Validate a single file path inside a skill directory.
|
|
136
163
|
*
|
|
@@ -142,6 +169,12 @@ const GLOBAL_TARGETS = Object.freeze([
|
|
|
142
169
|
* @returns {string | null}
|
|
143
170
|
*/
|
|
144
171
|
export function validateFilePath(rawPath) {
|
|
172
|
+
// Checked before decoding so no error message below (they interpolate
|
|
173
|
+
// the raw path verbatim) can ever echo a raw control character.
|
|
174
|
+
if (CONTROL_CHARS.test(rawPath)) {
|
|
175
|
+
return `Blocked control character in path "${escapeControlChars(rawPath)}".`;
|
|
176
|
+
}
|
|
177
|
+
|
|
145
178
|
// Decode URL-encoded characters before validation
|
|
146
179
|
let path;
|
|
147
180
|
try {
|
|
@@ -150,6 +183,12 @@ export function validateFilePath(rawPath) {
|
|
|
150
183
|
return `Invalid URL encoding in path "${rawPath}".`;
|
|
151
184
|
}
|
|
152
185
|
|
|
186
|
+
// Re-check after the single decode: %0A-style encodings become real
|
|
187
|
+
// control characters here.
|
|
188
|
+
if (CONTROL_CHARS.test(path)) {
|
|
189
|
+
return `Blocked control character in path "${escapeControlChars(path)}".`;
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
// Path traversal
|
|
154
193
|
if (path.includes("..")) {
|
|
155
194
|
return `Blocked path traversal in "${rawPath}".`;
|