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 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 refreshes on every session start without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.8.4",
3
+ "version": "4.9.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const skipFirstSync =
609
- Array.isArray(vendors) && vendors.length === 0 && !flags.global;
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 + syncSummary.updated + syncSummary.removed === 0;
698
+ syncSummary.added +
699
+ syncSummary.updated +
700
+ syncSummary.removed +
701
+ (syncSummary.skipped ?? 0) ===
702
+ 0;
686
703
 
687
- if (syncFailedReason) {
688
- // The warning already printed; the step-summary success line
689
- // would be misleading, so we skip it. Any helpful "next steps"
690
- // is in the final `SkillRepo is ready` block.
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
@@ -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
- const total = summary.added + summary.updated + summary.removed;
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.\n`,
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
- const total = s.added + s.updated + s.removed;
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
 
@@ -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}".`;