skillrepo 4.14.0 → 4.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.14.0",
3
+ "version": "4.14.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": {
@@ -65,6 +65,8 @@ import {
65
65
  *
66
66
  * - 304 Not Modified → exit 0, NO output.
67
67
  * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
68
+ * ("Skillset" replaces "Library" when a declared repo ran a scoped
69
+ * skillset sync — #2679.)
68
70
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
69
71
  * - Global-boundary disclosure (#2495): when the sync's summary
70
72
  * reports global skills that will also load in this session
@@ -172,6 +174,7 @@ export async function runUpdate(argv, io = {}) {
172
174
  const skipped = summary.skipped ?? 0;
173
175
  const total =
174
176
  summary.added + summary.updated + summary.removed + skipped;
177
+ const subject = syncSubject(summary);
175
178
  // Global-boundary disclosure (#2495), computed BEFORE the silent
176
179
  // branch below: the quiet 304/zero-delta/throttled session is
177
180
  // the COMMON session, and it must still disclose — the line
@@ -232,7 +235,7 @@ export async function runUpdate(argv, io = {}) {
232
235
  return;
233
236
  }
234
237
  stdout.write(
235
- `[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
238
+ `[SkillRepo] ${subject} synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
236
239
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
237
240
  `.\n`,
238
241
  );
@@ -338,17 +341,28 @@ export async function runUpdate(argv, io = {}) {
338
341
  printSummary(summary, stdout);
339
342
  }
340
343
 
344
+ // The noun for the scope a sync operated on (#2679). A declared repo's
345
+ // scoped sync carries a top-level `skillsetRef` (stamped by the runSync
346
+ // dispatcher on EVERY scoped return, including the grace/fail-closed path);
347
+ // a whole-library sync does not. Keying off THIS — not the #2665
348
+ // `compliance` object, whose presence excludes the non-fresh-scan paths —
349
+ // is what keeps a scoped grace sync from mislabeling itself "Library".
350
+ function syncSubject(summary) {
351
+ return summary.skillsetRef ? "Skillset" : "Library";
352
+ }
353
+
341
354
  function printSummary(s, out) {
342
355
  // `skipped` counts here: a run that dropped a skill is NOT "up to date",
343
356
  // and saying so was a false statement to the user (#2413 adversarial
344
357
  // review — the counter was incremented and never read).
345
358
  const skipped = s.skipped ?? 0;
346
359
  const total = s.added + s.updated + s.removed + skipped;
360
+ const subject = syncSubject(s);
347
361
  if (s.notModified || total === 0) {
348
- out.write("Library is up to date.\n");
362
+ out.write(`${subject} is up to date.\n`);
349
363
  return;
350
364
  }
351
- out.write("\n Library sync complete:\n");
365
+ out.write(`\n ${subject} sync complete:\n`);
352
366
  if (s.added > 0) out.write(` + ${s.added} added\n`);
353
367
  if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
354
368
  if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
package/src/lib/sync.mjs CHANGED
@@ -163,6 +163,24 @@
163
163
  * the throttled early-exit, and the
164
164
  * grace/fail-closed paths (no fresh
165
165
  * scans there to state a fact from).
166
+ * @property {string} [skillsetRef] - The skillset this run was scoped to
167
+ * (`declaration.use`), stamped by the
168
+ * `runSync` dispatcher on EVERY return of
169
+ * `runSkillsetScopedSync` — 200, 304,
170
+ * grace/fail-closed, and that body's own
171
+ * per-repo throttle exit. Unlike
172
+ * `compliance.skillsetRef` (a narrower
173
+ * subset), presence means "this run
174
+ * dispatched to the skillset-scoped body".
175
+ * ABSENT on whole-library syncs AND on the
176
+ * machine-global throttled early-exit in
177
+ * `runSync`'s own preamble — that path
178
+ * returns BEFORE the declaration is
179
+ * resolved, so a declared repo never
180
+ * reaches dispatch there (same exclusion
181
+ * `compliance` carries). Sound discriminator
182
+ * for scope-aware copy on any path that
183
+ * actually prints it (#2679).
166
184
  * @property {string} syncedAt - ISO timestamp of the sync: the server
167
185
  * response `syncedAt` on a 200, or the
168
186
  * previously-cached sync timestamp on a
@@ -947,7 +965,7 @@ export async function runSync(options) {
947
965
  // extracted verbatim; the golden wire-identity tests arbitrate that
948
966
  // its behavior is character-for-character unchanged.
949
967
  if (declarationResolution?.status === "declared") {
950
- return runSkillsetScopedSync({
968
+ const scopedSummary = await runSkillsetScopedSync({
951
969
  serverUrl,
952
970
  apiKey,
953
971
  vendors,
@@ -956,6 +974,18 @@ export async function runSync(options) {
956
974
  minSyncIntervalMs: intervalMs,
957
975
  resolution: declarationResolution,
958
976
  });
977
+ // Reliable top-level scope marker (#2679). EVERY scoped return is a
978
+ // skillset sync — including the grace/fail-closed path
979
+ // (`serveLastVerified`) and the defensive 304-no-prior branch, where the
980
+ // #2665 `compliance` object is intentionally ABSENT. Presentation
981
+ // (`printSummary`, the session-hook line) keys the "Skillset" vs
982
+ // "Library" noun off THIS field, never off `compliance.skillsetRef`
983
+ // (whose presence rules exclude the non-fresh-scan paths, so keying off
984
+ // it mislabels a scoped grace sync as "Library").
985
+ return {
986
+ ...scopedSummary,
987
+ skillsetRef: declarationResolution.declaration.use,
988
+ };
959
989
  }
960
990
 
961
991
  return runWholeLibrarySync({
@@ -988,6 +988,113 @@ describe("runUpdate — skillset-declaration gate (#2362)", () => {
988
988
  });
989
989
  });
990
990
 
991
+ // ── Scoped skillset sync says "Skillset", not "Library" (#2679) ────────
992
+ //
993
+ // In a declared repo, `update` runs a scoped skillset sync (sync.mjs
994
+ // dispatch) and the summary carries a top-level `skillsetRef` (stamped
995
+ // at the dispatcher on every scoped return), so the printer names that
996
+ // scope. An undeclared repo (whole library) stays byte-identical
997
+ // ("Library"). Sibling to #2672 (`list` vocabulary).
998
+
999
+ describe("runUpdate — scoped skillset sync vocabulary (#2679)", () => {
1000
+ beforeEach(setup);
1001
+ afterEach(teardown);
1002
+
1003
+ function declareRepo() {
1004
+ // .git bounds the declaration walk inside the sandbox project dir.
1005
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
1006
+ writeFileSync(
1007
+ join(sandbox, "project", "skillrepo.json"),
1008
+ JSON.stringify({
1009
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
1010
+ }),
1011
+ );
1012
+ }
1013
+
1014
+ function scopedResponse(skills) {
1015
+ return {
1016
+ skills,
1017
+ removals: [],
1018
+ syncedAt: "2026-08-20T00:00:00.000Z",
1019
+ skillset: {
1020
+ name: "backend-core",
1021
+ updatedAt: "2026-08-20T00:00:00.000Z",
1022
+ skippedExtras: [],
1023
+ },
1024
+ };
1025
+ }
1026
+
1027
+ it("no-op scoped sync prints 'Skillset is up to date', never 'Library'", async () => {
1028
+ declareRepo();
1029
+ server.setSkillsetResponse(scopedResponse([]));
1030
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1031
+ const out = stdout.text();
1032
+ assert.match(out, /Skillset is up to date/);
1033
+ assert.doesNotMatch(out, /Library is up to date/);
1034
+ });
1035
+
1036
+ it("scoped sync that writes a skill prints 'Skillset sync complete'", async () => {
1037
+ declareRepo();
1038
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1039
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1040
+ const out = stdout.text();
1041
+ assert.match(out, /Skillset sync complete/);
1042
+ assert.match(out, /added/);
1043
+ assert.doesNotMatch(out, /Library sync complete/);
1044
+ });
1045
+
1046
+ it("--session-hook scoped sync reports 'Skillset synced', not 'Library synced'", async () => {
1047
+ declareRepo();
1048
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1049
+ await runUpdate(
1050
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
1051
+ { stdout },
1052
+ );
1053
+ const out = stdout.text();
1054
+ assert.match(out, /\[SkillRepo\] Skillset synced: \d+ added/);
1055
+ assert.doesNotMatch(out, /Library synced/);
1056
+ });
1057
+
1058
+ it("an UNDECLARED repo keeps the 'Library' vocabulary (whole-library scope)", async () => {
1059
+ // Guards against over-broadening: no declaration → no skillsetRef →
1060
+ // the subject must remain "Library".
1061
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1062
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1063
+ const out = stdout.text();
1064
+ assert.match(out, /Library is up to date/);
1065
+ assert.doesNotMatch(out, /Skillset/);
1066
+ });
1067
+
1068
+ it("declared repo during a registry outage (grace path) still says 'Skillset'", async () => {
1069
+ // Regression for the review finding: the scoped grace path
1070
+ // (serveLastVerified) returns NO `compliance` object, so a discriminator
1071
+ // keyed off compliance.skillsetRef mislabeled it "Library". The reliable
1072
+ // top-level skillsetRef (sync.mjs dispatcher) fixes it. First a
1073
+ // successful scoped sync writes the per-repo verified state (which
1074
+ // requires a server ETag — see the `result.etag` gate in sync.mjs)...
1075
+ declareRepo();
1076
+ server.setSkillsetEtag('W/"ss-' + "a".repeat(64) + '"');
1077
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1078
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1079
+
1080
+ // ...then the registry goes unreachable; the interactive sync (never
1081
+ // throttled) serves last-verified and must keep the "Skillset" noun.
1082
+ server.setSkillsetError({
1083
+ status: 503,
1084
+ body: { error: "upstream flake" },
1085
+ headers: { "Retry-After": "30" },
1086
+ });
1087
+ const graceStdout = createCaptureStream();
1088
+ await runUpdate(
1089
+ ["--key", VALID_KEY, "--url", serverUrl],
1090
+ { stdout: graceStdout },
1091
+ );
1092
+ const graceOut = graceStdout.text();
1093
+ assert.match(graceOut, /Skillset is up to date/);
1094
+ assert.doesNotMatch(graceOut, /Library is up to date/);
1095
+ });
1096
+ });
1097
+
991
1098
  // ── Throttle window + declaration interplay through the hook flags ─────
992
1099
  // (#2362 prod-readiness QA): a throttled hook sync returns BEFORE the
993
1100
  // gate by documented design (#2174 zero-work contract). A repo that
@@ -1549,7 +1549,9 @@ describe("repo compliance state line (#2665) — always on while non-compliant",
1549
1549
  { stdout },
1550
1550
  );
1551
1551
  const out = stdout.text();
1552
- assert.match(out, /\[SkillRepo\] Library synced: 1 added/);
1552
+ // Declared repo scoped sync → the noun is "Skillset", not "Library"
1553
+ // (#2679; a top-level `skillsetRef` is stamped on the scoped path).
1554
+ assert.match(out, /\[SkillRepo\] Skillset synced: 1 added/);
1553
1555
  assert.match(
1554
1556
  out,
1555
1557
  /\[SkillRepo\] This repo does not match its skillset acme\/backend-core \(1 extra skill in the project\) — it is reported as not compliant\./,