skillrepo 4.12.0 → 4.13.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 CHANGED
@@ -119,11 +119,23 @@ and just write the config + gitignore.
119
119
  "removed": 0,
120
120
  "notModified": false,
121
121
  "fullSync": true,
122
- "syncedAt": "2026-05-01T00:00:00.000Z"
122
+ "syncedAt": "2026-05-01T00:00:00.000Z",
123
+ "globalBoundary": {
124
+ "total": 0,
125
+ "counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
126
+ "names": [],
127
+ "skillsetDeclared": false
128
+ }
123
129
  }
124
130
  }
125
131
  ```
126
132
 
133
+ `sync.globalBoundary` (4.13.0) appears on every successful
134
+ project-scope sync and describes the personal-scope skills that also
135
+ load in sessions here; it is absent when the scan did not run
136
+ (`--global`, or a scan failure). `names` stays local to your machine —
137
+ sync reporting to your organization carries counts only.
138
+
127
139
  Field notes:
128
140
  - `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
129
141
  - `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
@@ -183,6 +195,21 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
183
195
  at the repo root is gitignored: the skillset declaration only works as
184
196
  a committed file, so remove it from `.gitignore` and commit it.
185
197
 
198
+ Project syncs also disclose the personal scope (the global boundary):
199
+ skills under the global folders (`~/.claude/skills/`,
200
+ `~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
201
+ session run inside a project, whether or not they came through a
202
+ skillset. When any exist, a one-line disclosure says how many global
203
+ skills will also load — in a declared repo it also counts how many sit
204
+ outside the repo's skillset and how many collide with a skillset
205
+ member's name. Today the session auto-sync prints that line in Claude
206
+ Code only (the other agents' background sync hooks run silently by
207
+ their hook contracts — see `docs/vendor-paths.md` for each vendor's
208
+ channel); `skillrepo list` shows the same line everywhere. Interactive
209
+ syncs add a one-time warning per finding with the remediation, and
210
+ your organization's sync reporting carries counts only
211
+ (`global_library`, `global_foreign`, `global_shadowed`), never names.
212
+
186
213
  A repository can declare a skillset in a root `skillrepo.json`:
187
214
  `{"skillset": {"version": 1, "name": "<repo-identity>", "use":
188
215
  "owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.12.0",
3
+ "version": "4.13.0",
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": {
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
43
43
  import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
44
44
  import { getAgentByKey } from "../lib/agent-registry.mjs";
45
45
  import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
46
+ import {
47
+ scanGlobalBoundary,
48
+ formatGlobalBoundaryDisclosure,
49
+ managedGlobalNamesFrom,
50
+ resolveBoundaryMemberContext,
51
+ } from "../lib/global-boundary.mjs";
46
52
 
47
53
  /**
48
54
  * Run `list`. Throws CliError on any failure.
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
56
62
  const stdout = io.stdout ?? process.stdout;
57
63
  const flags = resolveFlags(argv);
58
64
 
59
- // `list` is a read-only drift check — it must NEVER set sync state.
65
+ // `list` is a read-only drift check — it must NEVER set sync state,
66
+ // and (#2495) it never writes the governance-seen file either: the
67
+ // boundary disclosure below scans without committing warn-state, so
68
+ // running `list` can't consume a warning `update` owes the user.
60
69
  // Use the manifest read (#1832): metadata only, no file bodies, and the
61
70
  // server records no delivery for it. Per-skill drift is computed from
62
71
  // on-disk SHAs + `.last-sync` below, never from the response body, so
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
122
131
 
123
132
  printTable(augmented, detected, stdout);
124
133
  printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
134
+ printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
135
+ }
136
+
137
+ /**
138
+ * Global-boundary disclosure for the table surface (#2495): one line,
139
+ * same format as the session-hook disclosure, printed only when the
140
+ * detected vendors' GLOBAL roots hold skill dirs. Read-only to the
141
+ * letter of list's contract above — no seen-state commit, no per-dir
142
+ * warning lines (those are `update`'s interactive job). Best-effort:
143
+ * the drift table must never fail because a disclosure probe did.
144
+ *
145
+ * The declaration is resolved TOLERANTLY, like the throttled sync
146
+ * exit: `list` is a reporting surface, so an invalid `skillrepo.json`
147
+ * stays `update`'s error to raise — here it just means the scan runs
148
+ * without a member set.
149
+ *
150
+ * @param {string[]} vendors - Detected vendor keys.
151
+ * @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
152
+ * @param {NodeJS.WritableStream} stdout
153
+ */
154
+ function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
155
+ try {
156
+ const { memberNames, baseDir } = resolveBoundaryMemberContext();
157
+ const scan = scanGlobalBoundary({
158
+ vendors,
159
+ memberNames,
160
+ managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
161
+ baseDir,
162
+ });
163
+ const disclosure = formatGlobalBoundaryDisclosure(scan);
164
+ // Two-space indent matches every other list line; the line itself
165
+ // is byte-identical to the session-hook disclosure.
166
+ if (disclosure) stdout.write(` ${disclosure}\n`);
167
+ } catch {
168
+ // Disclosure is best-effort on every surface.
169
+ }
125
170
  }
126
171
 
127
172
  // ── Per-skill augmentation ─────────────────────────────────────────────
@@ -46,6 +46,7 @@
46
46
  */
47
47
 
48
48
  import { runSync } from "../lib/sync.mjs";
49
+ import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
49
50
  import {
50
51
  resolveFlags,
51
52
  effectiveVendors,
@@ -64,6 +65,14 @@ import {
64
65
  * - 304 Not Modified → exit 0, NO output.
65
66
  * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
66
67
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
68
+ * - Global-boundary disclosure (#2495): when the sync's summary
69
+ * reports global skills that will also load in this session
70
+ * (`globalBoundary.total > 0`), ONE additional line prints on
71
+ * EVERY success path — after the sync line, or alone on the
72
+ * otherwise-silent 304/zero-delta/throttled paths. Plain stdout
73
+ * enters the session's model context, which is the point: the
74
+ * agent itself learns what extra skills are in play. Failure
75
+ * paths never disclose (the failure line stays the single line).
67
76
  *
68
77
  * The "exit 0 on all errors" contract is non-negotiable: a sync
69
78
  * failure must NEVER block a Claude Code session start. Users on a
@@ -162,10 +171,41 @@ export async function runUpdate(argv, io = {}) {
162
171
  const skipped = summary.skipped ?? 0;
163
172
  const total =
164
173
  summary.added + summary.updated + summary.removed + skipped;
174
+ // Global-boundary disclosure (#2495), computed BEFORE the silent
175
+ // branch below: the quiet 304/zero-delta/throttled session is
176
+ // the COMMON session, and it must still disclose — the line
177
+ // exists so the session's model context knows about the global
178
+ // skills loading alongside the synced set, not to report sync
179
+ // work. Success paths only; the catch below never reaches here.
180
+ // Formatting is its OWN failure domain (architect review r1): a
181
+ // formatter defect must degrade to "no line", never fall into
182
+ // the outer catch and report a successful sync as failed —
183
+ // "disclosure must never break a sync" applies to the printer
184
+ // exactly as it does to the scanner.
185
+ let disclosure = null;
186
+ try {
187
+ disclosure =
188
+ summary.globalBoundary && summary.globalBoundary.total > 0
189
+ ? formatGlobalBoundaryDisclosure(summary.globalBoundary)
190
+ : null;
191
+ } catch {
192
+ // Degrade to no disclosure line.
193
+ }
194
+ const writeDisclosureLine = () => {
195
+ if (!disclosure) return;
196
+ try {
197
+ stdout.write(`${disclosure}\n`);
198
+ } catch {
199
+ // Same failure domain as the formatter: a write failure on
200
+ // the cosmetic line must not become a "Sync failed" report.
201
+ }
202
+ };
165
203
  if (summary.notModified || total === 0) {
166
204
  // 304 Not Modified OR 200 with zero deltas — silent by
167
- // contract. Users should not see "Syncing..." on every
205
+ // contract (the boundary disclosure is the one sanctioned
206
+ // exception). Users should not see "Syncing..." on every
168
207
  // session for no visible value.
208
+ writeDisclosureLine();
169
209
  return;
170
210
  }
171
211
  stdout.write(
@@ -173,6 +213,7 @@ export async function runUpdate(argv, io = {}) {
173
213
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
174
214
  `.\n`,
175
215
  );
216
+ writeDisclosureLine();
176
217
  } catch (err) {
177
218
  // The one-line failure message is the user's primary signal
178
219
  // that something's wrong. Do not surface a stack trace — the
@@ -245,11 +245,41 @@ export function formatForeignWarnings(scan) {
245
245
  * @property {Record<string, boolean>} declarations - abs repo path →
246
246
  * true when the gitignored-declaration warning was already
247
247
  * shown and the declaration is still gitignored.
248
+ * @property {Record<string, Record<string, string[]>>} globalBoundary -
249
+ * abs repo path → (abs global root → dir names already warned
250
+ * about, sorted). The #2495 global-boundary counterpart to
251
+ * `roots`, keyed per REPO because the same global dir means
252
+ * different things in different repos (shadowing one repo's
253
+ * skillset member, plain foreign content elsewhere) — one
254
+ * repo consuming the warning must not silence it for another.
248
255
  */
249
256
 
250
257
  /** @returns {GovernanceSeenState} */
251
258
  function emptySeenState() {
252
- return { schemaVersion: 1, roots: {}, declarations: {} };
259
+ return { schemaVersion: 1, roots: {}, declarations: {}, globalBoundary: {} };
260
+ }
261
+
262
+ /**
263
+ * Shape-check the `globalBoundary` field on read: an object of objects
264
+ * of arrays, or `{}` when anything about it is malformed — the same
265
+ * warn-everything degradation the other fields use, applied to the
266
+ * whole field (a partially-trusted structure isn't worth salvaging
267
+ * when the cost of a reset is one repeated warning).
268
+ *
269
+ * @param {unknown} value
270
+ * @returns {Record<string, Record<string, string[]>>}
271
+ */
272
+ function coerceGlobalBoundary(value) {
273
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
274
+ for (const repoMap of Object.values(value)) {
275
+ if (!repoMap || typeof repoMap !== "object" || Array.isArray(repoMap)) {
276
+ return {};
277
+ }
278
+ for (const names of Object.values(repoMap)) {
279
+ if (!Array.isArray(names)) return {};
280
+ }
281
+ }
282
+ return /** @type {Record<string, Record<string, string[]>>} */ (value);
253
283
  }
254
284
 
255
285
  /** @returns {GovernanceSeenState} */
@@ -262,6 +292,11 @@ export function readGovernanceSeen() {
262
292
  // at the current schema. A future v2 that must PRESERVE v1 data
263
293
  // needs an explicit accept-in-place branch here, the way
264
294
  // sync.mjs's readLastSync accepts its immediately-prior schema.
295
+ // `globalBoundary` (#2495) is an ADDITIVE key at schemaVersion 1,
296
+ // not a bump: a pre-#2495 CLI sharing this file simply drops the
297
+ // key on its next write, and the cost is one repeated boundary
298
+ // warning on the next post-#2495 interactive sync — documented,
299
+ // acceptable, and self-healing.
265
300
  if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1) {
266
301
  return emptySeenState();
267
302
  }
@@ -277,6 +312,7 @@ export function readGovernanceSeen() {
277
312
  !Array.isArray(parsed.declarations)
278
313
  ? parsed.declarations
279
314
  : {},
315
+ globalBoundary: coerceGlobalBoundary(parsed.globalBoundary),
280
316
  };
281
317
  } catch {
282
318
  return emptySeenState();
@@ -396,7 +432,110 @@ export function commitGovernanceSeen({ scan, repoKey, declarationIgnored }) {
396
432
  } else {
397
433
  delete declarations[repoKey];
398
434
  }
399
- writeGovernanceSeen({ schemaVersion: 1, roots, declarations });
435
+ writeGovernanceSeen({
436
+ schemaVersion: 1,
437
+ roots,
438
+ declarations,
439
+ // Pass-through from the FRESH read (#2495): this commit owns only
440
+ // `roots` + this repo's declaration flag; the boundary scan's keys
441
+ // belong to `commitGlobalBoundarySeen`, and dropping them here
442
+ // would re-warn every boundary finding after every project sync.
443
+ globalBoundary: fresh.globalBoundary,
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Commit a global-boundary scan (#2495) into the seen-state file with
449
+ * the same NARROW merge discipline as `commitGovernanceSeen`: fresh
450
+ * read, rewrite ONLY `globalBoundary[repoKey]` (this repo's view), and
451
+ * pass `roots`/`declarations` — plus every OTHER repo's boundary key —
452
+ * through from the fresh read, so a concurrent sync elsewhere keeps
453
+ * its update even if this process writes last.
454
+ *
455
+ * Replace-not-merge per scanned root within the repo key: dirs that
456
+ * disappeared are pruned so a removed-then-reappearing dir warns
457
+ * again; a clean root deletes its key, and a repo whose every scanned
458
+ * root ended clean deletes its whole entry (state files should not
459
+ * accumulate empty husks).
460
+ *
461
+ * The RESIDUAL same-key race documented on `writeGovernanceSeen`
462
+ * applies here identically: two concurrent interactive syncs of ONE
463
+ * repo last-write-win on that repoKey, which can revive a pruned
464
+ * entry and suppress a re-warn — a repeated-or-suppressed warning,
465
+ * never a wrong receipt count (counts come from the live scan).
466
+ *
467
+ * @param {object} options
468
+ * @param {{ roots: { root: string, entries: { name: string }[] }[] }} options.scan
469
+ * The FULL boundary scan (everything currently on disk, not
470
+ * just the newly-warned entries).
471
+ * @param {string} options.repoKey - Normalized repo path.
472
+ */
473
+ export function commitGlobalBoundarySeen({ scan, repoKey }) {
474
+ const fresh = readGovernanceSeen();
475
+ const globalBoundary = { ...fresh.globalBoundary };
476
+ const repoMap = { ...(globalBoundary[repoKey] ?? {}) };
477
+ for (const rootResult of scan?.roots ?? []) {
478
+ const names = (rootResult.entries ?? []).map((entry) => entry.name).sort();
479
+ if (names.length > 0) {
480
+ repoMap[rootResult.root] = names;
481
+ } else {
482
+ delete repoMap[rootResult.root];
483
+ }
484
+ }
485
+ if (Object.keys(repoMap).length === 0) {
486
+ delete globalBoundary[repoKey];
487
+ } else {
488
+ globalBoundary[repoKey] = repoMap;
489
+ }
490
+ writeGovernanceSeen({
491
+ schemaVersion: 1,
492
+ roots: fresh.roots,
493
+ declarations: fresh.declarations,
494
+ globalBoundary,
495
+ });
496
+ }
497
+
498
+ /**
499
+ * Select the boundary findings this repo has NOT been warned about
500
+ * yet. Pure — no IO; the `commitGlobalBoundarySeen` above does the
501
+ * bookkeeping. Mirrors `selectNewFindings`, keyed one level deeper
502
+ * (per repo, then per root).
503
+ *
504
+ * Gates ONLY the interactive warning lines: the disclosure line and
505
+ * the receipt categories always use the FULL scan — seen-state
506
+ * suppresses repetition for a human, never facts for the record.
507
+ *
508
+ * @param {import("./global-boundary.mjs").GlobalBoundaryScan} scan
509
+ * @param {GovernanceSeenState} seen
510
+ * @param {string} repoKey - Normalized repo path.
511
+ * @returns {{ roots: object[], counts: Record<string, number>, total: number }}
512
+ * Scan-shaped (roots/counts/total recomputed over the unseen
513
+ * entries). No `summary` — the disclosure surfaces never
514
+ * consume a filtered scan, so embedding one here would invite
515
+ * exactly the partial-disclosure bug the gating rule forbids.
516
+ */
517
+ export function selectNewGlobalBoundaryFindings(scan, seen, repoKey) {
518
+ const repoMap =
519
+ seen?.globalBoundary && typeof seen.globalBoundary === "object"
520
+ ? seen.globalBoundary[repoKey] ?? {}
521
+ : {};
522
+ const counts = {};
523
+ for (const key of Object.keys(scan?.counts ?? {})) counts[key] = 0;
524
+ const result = { roots: [], counts, total: 0 };
525
+ for (const rootResult of scan?.roots ?? []) {
526
+ const prior = new Set(
527
+ Array.isArray(repoMap[rootResult.root]) ? repoMap[rootResult.root] : [],
528
+ );
529
+ const freshEntries = (rootResult.entries ?? []).filter(
530
+ (entry) => !prior.has(entry.name),
531
+ );
532
+ result.roots.push({ ...rootResult, entries: freshEntries });
533
+ for (const entry of freshEntries) {
534
+ counts[entry.bucket] = (counts[entry.bucket] ?? 0) + 1;
535
+ result.total += 1;
536
+ }
537
+ }
538
+ return result;
400
539
  }
401
540
 
402
541
  /**