skillrepo 4.14.1 → 4.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.14.1",
3
+ "version": "4.15.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": {
@@ -8,7 +8,23 @@
8
8
  * reports the library-level ETag state ("library in sync" vs
9
9
  * "library has changed since last sync").
10
10
  *
11
- * Pipeline:
11
+ * Skillset-declared repos (#2672, epic #2357). When cwd walks up to a
12
+ * `skillrepo.json` that DECLARES a skillset, `list` shows that repo's
13
+ * DELIVERED view instead of the whole library: rows are the per-repo
14
+ * state file's `resolved` manifest (the same set the sync receipts
15
+ * attest — NOT a live re-resolution of the declaration), drift is
16
+ * computed against the per-repo SHA baselines and the DETECTED-ROOT
17
+ * placements (never `.last-sync`/cwd, which scoped syncs bypass by
18
+ * design — D12), and the #2665 compliance line is appended. This path
19
+ * is fully offline (no library GET) and read-only. An `invalid`
20
+ * declaration fails closed exactly as `sync` does — a mis-declared
21
+ * repo is never shown the whole library (D9). Undeclared and
22
+ * config-only repos keep the whole-library view below, byte-identical
23
+ * to pre-#2672 (D2). Recall status is deliberately NOT surfaced here
24
+ * (the CLI has no recall signal — recall is absence in delivery);
25
+ * tracked separately in #2793.
26
+ *
27
+ * Pipeline (whole-library view):
12
28
  * 1. `getLibrary` returns the current registry skills + ETag.
13
29
  * 2. `readLastSync` reads the v2 `.last-sync` map (per-skill SHAs +
14
30
  * versions) for the on-disk-vs-synced comparison.
@@ -47,8 +63,18 @@ import {
47
63
  scanGlobalBoundary,
48
64
  formatGlobalBoundaryDisclosure,
49
65
  managedGlobalNamesFrom,
50
- resolveBoundaryMemberContext,
66
+ GLOBAL_SHADOWED_CATEGORY,
67
+ GLOBAL_LIBRARY_CATEGORY,
68
+ GLOBAL_FOREIGN_CATEGORY,
51
69
  } from "../lib/global-boundary.mjs";
70
+ import { resolveDeclaration } from "../lib/skillset-declaration.mjs";
71
+ import { readRepoSyncState } from "../lib/repo-sync-state.mjs";
72
+ import {
73
+ scanForeignContent,
74
+ buildRepoCompliance,
75
+ formatRepoComplianceSummary,
76
+ } from "../lib/foreign-content.mjs";
77
+ import { escapeControlChars } from "../lib/file-write.mjs";
52
78
 
53
79
  /**
54
80
  * Run `list`. Throws CliError on any failure.
@@ -60,8 +86,43 @@ import {
60
86
  */
61
87
  export async function runList(argv, io = {}) {
62
88
  const stdout = io.stdout ?? process.stdout;
89
+ const stderr = io.stderr ?? process.stderr;
63
90
  const flags = resolveFlags(argv);
64
91
 
92
+ // #2672: dispatch on the repo's skillset declaration BEFORE any
93
+ // network call, so the skillset path stays fully offline. Resolution
94
+ // is local-only and never throws for the undeclared cases; `invalid`
95
+ // carries a typed fail-closed error.
96
+ const declaration = resolveDeclaration();
97
+
98
+ if (declaration.status === "invalid") {
99
+ // Fail closed, exactly as `sync` does (D9): a mis-declared repo must
100
+ // never be shown the whole library — that fail-OPEN is the bug this
101
+ // issue removes. The dispatcher prints this typed error cleanly.
102
+ throw declaration.error;
103
+ }
104
+
105
+ if (declaration.status === "declared") {
106
+ return runSkillsetListView({ declaration, flags, stdout, stderr });
107
+ }
108
+
109
+ // `absent` | `config-only` → the whole-library view, byte-identical
110
+ // to pre-#2672 (D2).
111
+ return runLibraryListView({ flags, stdout });
112
+ }
113
+
114
+ /**
115
+ * The whole-library `list` view (#679 / #1555): fetch the manifest,
116
+ * classify each library skill's on-disk drift against `.last-sync`, and
117
+ * render the table + ETag footer + global-boundary disclosure. Reached
118
+ * for undeclared and config-only repos; behavior is unchanged from
119
+ * pre-#2672.
120
+ *
121
+ * @param {object} args
122
+ * @param {ReturnType<typeof resolveFlags>} args.flags
123
+ * @param {NodeJS.WritableStream} args.stdout
124
+ */
125
+ async function runLibraryListView({ flags, stdout }) {
65
126
  // `list` is a read-only drift check — it must NEVER set sync state,
66
127
  // and (#2495) it never writes the governance-seen file either: the
67
128
  // boundary disclosure below scans without committing warn-state, so
@@ -153,7 +214,15 @@ export async function runList(argv, io = {}) {
153
214
  */
154
215
  function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
155
216
  try {
156
- const { memberNames, baseDir } = resolveBoundaryMemberContext();
217
+ // Reached ONLY for undeclared / config-only repos — the dispatcher
218
+ // routes declared repos to the skillset view and throws on invalid —
219
+ // so there is never a skillset member set here. `resolveBoundaryMemberContext()`
220
+ // would just re-walk the declaration the dispatcher already resolved
221
+ // and return exactly `{ memberNames: null, baseDir: undefined }`;
222
+ // inlining it avoids that redundant second walk-up per plain `list`
223
+ // (#2672 review).
224
+ const memberNames = null;
225
+ const baseDir = undefined;
157
226
  const scan = scanGlobalBoundary({
158
227
  vendors,
159
228
  memberNames,
@@ -255,6 +324,391 @@ function validateBaselineShape(entry) {
255
324
  return entry;
256
325
  }
257
326
 
327
+ // ── Skillset-declared view (#2672) ─────────────────────────────────────
328
+
329
+ /**
330
+ * Sentinel status for a resolved member that was SERVED but not written
331
+ * to disk by the CLI (`written: false`). Distinct from the four
332
+ * `SKILL_STATE` drift values, which only apply to delivered members.
333
+ */
334
+ const SKILLSET_NOT_DELIVERED = "not-delivered";
335
+
336
+ /**
337
+ * Render `list` for a skillset-declared repo: the repo's DELIVERED set,
338
+ * sourced from the per-repo state file (`repo-sync-state.mjs`) — the
339
+ * same manifest the sync receipts attest, NOT a live re-resolution of
340
+ * the declaration. Fully offline and read-only: no library GET, no state
341
+ * writes, no `.governance-seen` writes.
342
+ *
343
+ * Sub-cases:
344
+ * - No per-repo state (declared but never scoped-synced here) → say so
345
+ * and point at `skillrepo update`; NEVER fall back to the library
346
+ * view (that silent fallback is the D9 shape this issue removes).
347
+ * - Have state → the delivered manifest as rows (per-row drift for
348
+ * written members against the per-repo SHA baseline at the detected
349
+ * root; a distinct "not delivered" row for served-but-refused
350
+ * members) plus the #2665 compliance line, composed the way `sync`'s
351
+ * 304 path does — REPLAY the delivery-reason counts from `resolved`,
352
+ * RE-SCAN foreign + global content fresh from disk (read-only).
353
+ *
354
+ * @param {object} args
355
+ * @param {import("../lib/skillset-declaration.mjs").DeclarationResolution} args.declaration
356
+ * @param {ReturnType<typeof resolveFlags>} args.flags
357
+ * @param {NodeJS.WritableStream} args.stdout
358
+ * @param {NodeJS.WritableStream} args.stderr
359
+ */
360
+ function runSkillsetListView({ declaration, flags, stdout, stderr }) {
361
+ const rootDir = declaration.rootDir;
362
+ const skillsetRef = declaration.declaration.use;
363
+ const repoName = declaration.declaration.name;
364
+
365
+ // The state reader warns ONCE on a corrupt file — that diagnostic
366
+ // belongs on stderr, never stdout (which `--json` consumers parse).
367
+ const state = readRepoSyncState(rootDir, { stderr });
368
+
369
+ if (!state) {
370
+ // Declared but no readable per-repo state → never scoped-synced here
371
+ // (or the state file was corrupt, which the reader already flagged on
372
+ // stderr). Either way the remediation is a sync — NOT a library-view
373
+ // fallback.
374
+ if (flags.json) {
375
+ stdout.write(
376
+ JSON.stringify(
377
+ { skillset: { ref: skillsetRef, repo: repoName, synced: false }, skills: [] },
378
+ null,
379
+ 2,
380
+ ) + "\n",
381
+ );
382
+ return;
383
+ }
384
+ stdout.write(
385
+ `\n This repo declares skillset ${skillsetRef}, but there is no local sync state for it yet.\n` +
386
+ " Run `skillrepo update` to fetch it.\n\n",
387
+ );
388
+ return;
389
+ }
390
+
391
+ const detected = detectAgents().filter((d) => d.detected);
392
+ const detectedVendorEntries = detected
393
+ .map((d) => getAgentByKey(d.key))
394
+ .filter((entry) => entry !== null && entry.projectTarget !== null);
395
+ const detectedKeys = detectedVendorEntries.map((e) => e.key);
396
+
397
+ // Placements anchored at the DETECTED ROOT (#2672), not cwd.
398
+ const placementsMap = walkDetectedPlacements(detectedKeys, rootDir);
399
+
400
+ const resolved = Array.isArray(state.resolved) ? state.resolved : [];
401
+ const members = resolved.map((entry) =>
402
+ augmentMember(entry, detectedVendorEntries, placementsMap),
403
+ );
404
+
405
+ const { compliance, gbScan } = evaluateSkillsetGovernance({
406
+ skillsetRef,
407
+ resolved,
408
+ detectedKeys,
409
+ rootDir,
410
+ });
411
+
412
+ if (flags.json) {
413
+ stdout.write(
414
+ JSON.stringify(
415
+ formatSkillsetJson({ skillsetRef, repoName, members, compliance }),
416
+ null,
417
+ 2,
418
+ ) + "\n",
419
+ );
420
+ return;
421
+ }
422
+
423
+ printSkillsetTable({ skillsetRef, repoName, detected, members, compliance, gbScan, out: stdout });
424
+ }
425
+
426
+ /**
427
+ * Per-member drift for the skillset view. `written: true` members are
428
+ * classified against their per-repo SHA baseline exactly like the
429
+ * library view (reusing `computeSkillState`), but with the SERVED
430
+ * version as both the library version and the baseline version — so this
431
+ * view has no STALE axis (the delivered version IS the approved one; an
432
+ * upstream bump is invisible offline). `written: false` members were
433
+ * served but the CLI refused to write them; they carry no baseline and
434
+ * become a distinct "not delivered" row keyed by `unwrittenReason`.
435
+ *
436
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry} entry
437
+ * @param {import("../lib/agent-registry.mjs").AgentEntry[]} detectedVendorEntries
438
+ * @param {Map<string, import("../lib/placement-walk.mjs").LocalPlacement>} placementsMap
439
+ */
440
+ function augmentMember(entry, detectedVendorEntries, placementsMap) {
441
+ const owner = entry.owner;
442
+ const name = entry.name;
443
+ const version = entry.version ?? null;
444
+
445
+ if (entry.written !== true) {
446
+ return {
447
+ owner,
448
+ name,
449
+ version,
450
+ delivered: false,
451
+ reason: typeof entry.unwrittenReason === "string" ? entry.unwrittenReason : null,
452
+ status: SKILLSET_NOT_DELIVERED,
453
+ placements: [],
454
+ };
455
+ }
456
+
457
+ const baseline = validateBaselineShape({
458
+ version: entry.version,
459
+ skillMdSha256: entry.skillMdSha256,
460
+ filesSha256: entry.filesSha256,
461
+ });
462
+ const placements = [];
463
+ for (const vendorEntry of detectedVendorEntries) {
464
+ const onDisk = placementsMap.get(`${vendorEntry.key}::project::${name}`) ?? null;
465
+ const localPlacement = onDisk
466
+ ? { present: true, skillMdSha256: onDisk.skillMdSha256, filesSha256: onDisk.filesSha256 }
467
+ : { present: false, skillMdSha256: null, filesSha256: null };
468
+ const state = computeSkillState({
469
+ libraryVersion: version,
470
+ lastSyncEntry: baseline,
471
+ localPlacement,
472
+ });
473
+ placements.push({ vendor: vendorEntry.key, scope: "project", state });
474
+ }
475
+ const status = rollupState(placements.map((p) => p.state));
476
+ return { owner, name, version, delivered: true, reason: null, status, placements };
477
+ }
478
+
479
+ /**
480
+ * Run the read-only governance scans for the skillset view ONCE and
481
+ * return both the #2665 compliance object and the raw global-boundary
482
+ * scan (the latter feeds the #2495 disclosure line, so both surfaces
483
+ * share one scan). Compliance is composed the way `sync`'s 304 path
484
+ * does: REPLAY the delivery-reason counts from `resolved` (via
485
+ * `buildRepoCompliance`) and RE-SCAN foreign + global content fresh from
486
+ * disk. Uses the raw `scanForeignContent` / `scanGlobalBoundary` — never
487
+ * `sync`'s `runGlobalBoundaryScan`, which also emits warnings and would
488
+ * commit seen-state (forbidden here). Best-effort: a scan failure leaves
489
+ * the scan-derived counts at zero (and `gbScan` null), so the
490
+ * delivery-reason half of compliance still stands.
491
+ *
492
+ * @param {object} args
493
+ * @param {string} args.skillsetRef
494
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry[]} args.resolved
495
+ * @param {string[]} args.detectedKeys
496
+ * @param {string} args.rootDir
497
+ * @returns {{ compliance: ReturnType<typeof buildRepoCompliance>, gbScan: (import("../lib/global-boundary.mjs").GlobalBoundaryScan | null) }}
498
+ */
499
+ function evaluateSkillsetGovernance({ skillsetRef, resolved, detectedKeys, rootDir }) {
500
+ const managedSkills = {};
501
+ for (const e of resolved) {
502
+ if (e && e.owner && e.name) {
503
+ managedSkills[`${e.owner}/${e.name}`] = { version: e.version };
504
+ }
505
+ }
506
+
507
+ let foreignCount = 0;
508
+ let gbScan = null;
509
+ try {
510
+ foreignCount = scanForeignContent({
511
+ vendors: detectedKeys,
512
+ global: false,
513
+ managedSkills,
514
+ baseDir: rootDir,
515
+ }).foreignCount;
516
+ gbScan = scanGlobalBoundary({
517
+ vendors: detectedKeys,
518
+ memberNames: new Set(resolved.map((e) => e.name)),
519
+ managedGlobalNames: managedGlobalNamesFrom(readLastSync()?.skills),
520
+ baseDir: rootDir,
521
+ });
522
+ } catch {
523
+ // Best-effort: a scan failure must never crash the delivered-set view.
524
+ }
525
+
526
+ const compliance = buildRepoCompliance({
527
+ skillsetRef,
528
+ foreignCount,
529
+ shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
530
+ globalBeyondCount:
531
+ (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
532
+ (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
533
+ resolved,
534
+ });
535
+ return { compliance, gbScan };
536
+ }
537
+
538
+ /**
539
+ * Skillset `--json` shape (#2672). Undeclared repos keep the #679 bare
540
+ * array (see `formatJson`); a declared repo grows a top-level object so
541
+ * the skillset ref and machine-readable compliance have a home — no
542
+ * existing script consumes `list --json` inside a declared repo, so this
543
+ * new shape breaks no contract. Recall is absent by design (#2793).
544
+ */
545
+ function formatSkillsetJson({ skillsetRef, repoName, members, compliance }) {
546
+ return {
547
+ skillset: {
548
+ ref: skillsetRef,
549
+ repo: repoName,
550
+ synced: true,
551
+ // Single source of truth for "compliant": the formatter returns
552
+ // null iff there are zero causes, so a future 7th cause updates both
553
+ // the warning line and this flag in lockstep (#2672 review).
554
+ compliant: formatRepoComplianceSummary(compliance) === null,
555
+ compliance: {
556
+ unmanagedCount: compliance.unmanagedCount,
557
+ shadowedCount: compliance.shadowedCount,
558
+ globalBeyondCount: compliance.globalBeyondCount,
559
+ editRefusedCount: compliance.editRefusedCount,
560
+ memberReplacedCount: compliance.memberReplacedCount,
561
+ notDeliveredCount: compliance.notDeliveredCount,
562
+ },
563
+ },
564
+ skills: members
565
+ .slice()
566
+ .sort(sortByOwnerAndName)
567
+ .map((m) => ({
568
+ owner: m.owner,
569
+ name: m.name,
570
+ version: m.version,
571
+ delivered: m.delivered,
572
+ state: m.delivered ? m.status : SKILLSET_NOT_DELIVERED,
573
+ ...(m.delivered ? {} : { reason: m.reason }),
574
+ placements: m.placements,
575
+ })),
576
+ };
577
+ }
578
+
579
+ /**
580
+ * Human table for the skillset view. Columns are Skill / Version /
581
+ * Status — no Updated/Description (the per-repo state carries neither;
582
+ * fetching them would mean a library GET this offline view deliberately
583
+ * avoids). The #2665 compliance line is appended via the SAME formatter
584
+ * `sync` uses; when compliant, a positive one-liner replaces it.
585
+ */
586
+ function printSkillsetTable({ skillsetRef, repoName, detected, members, compliance, gbScan, out }) {
587
+ const useGlyphs = canUseGlyphs(out);
588
+
589
+ out.write(`\n Skillset: ${skillsetRef}${repoName ? ` (declared as ${repoName})` : ""}\n`);
590
+ if (detected.length > 0) {
591
+ const detectedLabel = detected.map((d) => `${d.displayName} (project)`).join(", ");
592
+ out.write(` Detected: ${detectedLabel}\n`);
593
+ } else {
594
+ out.write(" No agents detected in this project.\n");
595
+ }
596
+
597
+ const sorted = members.slice().sort(sortByOwnerAndName);
598
+
599
+ if (sorted.length === 0) {
600
+ out.write(`\n Skillset ${skillsetRef} resolves to no members.\n\n`);
601
+ } else {
602
+ const table = new Table({
603
+ head: ["Skill", "Version", "Status"],
604
+ colWidths: computeSkillsetColWidths(streamColumns(out)),
605
+ wordWrap: true,
606
+ style: { head: ["bold"] },
607
+ });
608
+ for (const m of sorted) {
609
+ // Escape control chars in the state-file-sourced identifier and
610
+ // version before printing (#2402 class). The per-repo state file is
611
+ // filesystem-sourced and could be corrupt or tampered; a raw ANSI
612
+ // sequence in a member `name` would otherwise clear the screen or
613
+ // spoof the compliance verdict this view exists to report truthfully.
614
+ // `cli-table3` emits cell content verbatim (it only ignores escapes
615
+ // for width). `--json` needs no escaping — JSON.stringify handles it.
616
+ table.push([
617
+ escapeControlChars(formatIdentifier(m)),
618
+ escapeControlChars(String(m.version ?? "")) || "—",
619
+ renderSkillsetStatusCell(m, useGlyphs),
620
+ ]);
621
+ }
622
+ out.write("\n" + table.toString() + "\n\n");
623
+
624
+ // "needs attention" = any delivered member that isn't CURRENT, plus
625
+ // every not-delivered member.
626
+ const attention = sorted.filter(
627
+ (m) => !m.delivered || m.status !== SKILL_STATE.CURRENT,
628
+ ).length;
629
+ const total = sorted.length;
630
+ if (attention === 0) {
631
+ out.write(` ${total} member${total === 1 ? "" : "s"} delivered and current.\n`);
632
+ } else {
633
+ out.write(
634
+ ` ${total} member${total === 1 ? "" : "s"} in skillset ${skillsetRef}. ` +
635
+ `${attention} need${attention === 1 ? "s" : ""} attention.\n`,
636
+ );
637
+ }
638
+ }
639
+
640
+ // #2665 compliance state line — same formatter as `sync`, appended when
641
+ // any cause is present (the formatter returns null when compliant).
642
+ const line = formatRepoComplianceSummary(compliance);
643
+ if (line) {
644
+ out.write(`${line}\n`);
645
+ } else {
646
+ out.write(` ${useGlyphs ? "✓" : "[ok]"} compliant with skillset ${skillsetRef}.\n`);
647
+ }
648
+
649
+ // #2495 global-boundary disclosure — the same read-only one-liner the
650
+ // library view and session hook print, naming the global skills that
651
+ // ALSO load in this session. Complementary to the compliance verdict
652
+ // above (operational awareness vs governance verdict); best-effort, so
653
+ // it never fails the view.
654
+ try {
655
+ const disclosure = gbScan ? formatGlobalBoundaryDisclosure(gbScan) : null;
656
+ if (disclosure) out.write(` ${disclosure}\n`);
657
+ } catch {
658
+ // Disclosure is best-effort on every surface.
659
+ }
660
+ out.write("\n");
661
+ }
662
+
663
+ /**
664
+ * Status cell for a skillset member. Delivered members use the drift
665
+ * vocabulary; not-delivered members key their copy off the refusal
666
+ * reason, matching the compliance line ("edited locally" / "replaced").
667
+ */
668
+ function renderSkillsetStatusCell(m, useGlyphs) {
669
+ if (!m.delivered) {
670
+ const detail =
671
+ m.reason === "modified"
672
+ ? "edited locally"
673
+ : m.reason === "unmanaged"
674
+ ? "replaced"
675
+ : m.reason === "incomplete"
676
+ ? "incomplete"
677
+ : m.reason === "invalid"
678
+ ? "invalid"
679
+ : null;
680
+ const base = useGlyphs ? "⊘ not delivered" : "NOT DELIVERED";
681
+ return detail ? `${base} · ${detail}` : base;
682
+ }
683
+ switch (m.status) {
684
+ case SKILL_STATE.CURRENT:
685
+ return useGlyphs ? "✓ current" : "OK";
686
+ case SKILL_STATE.EDITED:
687
+ return useGlyphs ? "✎ edited" : "EDIT";
688
+ case SKILL_STATE.MISSING:
689
+ return useGlyphs ? "✗ missing" : "MISS";
690
+ default:
691
+ // Only current/edited/missing are reachable for a delivered member —
692
+ // its served version IS its baseline version, so `computeSkillState`
693
+ // never returns `stale` here. `?` is the defensive catch-all.
694
+ return useGlyphs ? "?" : "?";
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Three-column widths for the skillset table (Skill / Version /
700
+ * Status), same cap/floor policy as the library table's
701
+ * `computeColWidths`.
702
+ */
703
+ function computeSkillsetColWidths(terminalColumns) {
704
+ const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
705
+ const skillCol = 34;
706
+ const versionCol = 12;
707
+ // -4 accounts for the 2 between-column borders + edge padding.
708
+ const statusCol = Math.max(20, total - skillCol - versionCol - 4);
709
+ return [skillCol, versionCol, statusCol];
710
+ }
711
+
258
712
  // ── JSON formatter ─────────────────────────────────────────────────────
259
713
 
260
714
  /**
@@ -334,6 +334,64 @@ export function formatRepoComplianceSummary({
334
334
  );
335
335
  }
336
336
 
337
+ /**
338
+ * Bucket a per-repo `resolved` manifest into the #2665 compliance
339
+ * object. Pure — no I/O, no printing — so both `sync` (which prints the
340
+ * line to stderr, `composeRepoCompliance`) and `list` (#2672, which
341
+ * renders it under the skillset table) share ONE definition of how
342
+ * served-but-not-written entries map to the three delivery-reason
343
+ * buckets.
344
+ *
345
+ * The scan-derived counts (`foreignCount`, `shadowedCount`,
346
+ * `globalBeyondCount`) are computed by the caller from the fresh
347
+ * foreign + global-boundary scans and passed in — keeping the
348
+ * global-boundary category constants out of this module (there is a
349
+ * one-directional import global-boundary → foreign-content already, so
350
+ * importing them back would cycle). This function owns only the
351
+ * `resolved`-derived half, the part identical wherever a per-repo state
352
+ * is read: `written: true` entries never contribute (they were
353
+ * delivered); `written: false` entries bucket by `unwrittenReason` —
354
+ * "modified" → editRefused (the one bucket allowed to claim a local
355
+ * edit), "unmanaged" → memberReplaced, everything else
356
+ * (incomplete/invalid, or a legacy entry with no reason) →
357
+ * notDelivered, never phrased as an edit.
358
+ *
359
+ * @param {object} input
360
+ * @param {string} input.skillsetRef
361
+ * @param {number} [input.foreignCount] - Project-scope foreign dirs
362
+ * (the scan's `foreignCount`) → `unmanagedCount`.
363
+ * @param {number} [input.shadowedCount]
364
+ * @param {number} [input.globalBeyondCount]
365
+ * @param {Array<{written?: boolean, unwrittenReason?: string}>} [input.resolved]
366
+ * @returns {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}}
367
+ */
368
+ export function buildRepoCompliance({
369
+ skillsetRef,
370
+ foreignCount = 0,
371
+ shadowedCount = 0,
372
+ globalBeyondCount = 0,
373
+ resolved = [],
374
+ }) {
375
+ let editRefusedCount = 0;
376
+ let memberReplacedCount = 0;
377
+ let notDeliveredCount = 0;
378
+ for (const e of resolved ?? []) {
379
+ if (!e || e.written === true) continue;
380
+ if (e.unwrittenReason === "modified") editRefusedCount += 1;
381
+ else if (e.unwrittenReason === "unmanaged") memberReplacedCount += 1;
382
+ else notDeliveredCount += 1;
383
+ }
384
+ return {
385
+ skillsetRef,
386
+ unmanagedCount: foreignCount ?? 0,
387
+ shadowedCount: shadowedCount ?? 0,
388
+ globalBeyondCount: globalBeyondCount ?? 0,
389
+ editRefusedCount,
390
+ memberReplacedCount,
391
+ notDeliveredCount,
392
+ };
393
+ }
394
+
337
395
  // ── Warn-on-new state (#2361 owner directive: no repeat warnings) ──────
338
396
  //
339
397
  // A warning that repeats unchanged findings on every sync trains users
@@ -68,9 +68,13 @@ import { getAgentByKey } from "./agent-registry.mjs";
68
68
  * vendors that map to the same target share the walk's results.
69
69
  *
70
70
  * @param {string[]} detectedVendorKeys
71
+ * @param {string} [baseDir] - Project-scope anchor (#2672): the skillset
72
+ * view passes the declaration's DETECTED ROOT so placements are read
73
+ * from the repo root, not cwd. Omitted (undefined) → today's
74
+ * cwd-anchored behavior, byte-identical for the whole-library view.
71
75
  * @returns {Map<string, LocalPlacement>}
72
76
  */
73
- export function walkDetectedPlacements(detectedVendorKeys) {
77
+ export function walkDetectedPlacements(detectedVendorKeys, baseDir) {
74
78
  if (!Array.isArray(detectedVendorKeys) || detectedVendorKeys.length === 0) {
75
79
  return new Map();
76
80
  }
@@ -91,7 +95,7 @@ export function walkDetectedPlacements(detectedVendorKeys) {
91
95
  /** @type {Map<string, LocalPlacement>} */
92
96
  const result = new Map();
93
97
  for (const [target, vendorKeys] of targetToVendors) {
94
- const skillsAtTarget = walkPlacementTarget(target);
98
+ const skillsAtTarget = walkPlacementTarget(target, baseDir);
95
99
  for (const skill of skillsAtTarget) {
96
100
  for (const vendorKey of vendorKeys) {
97
101
  const key = `${vendorKey}::project::${skill.skillName}`;
@@ -119,12 +123,14 @@ export function walkDetectedPlacements(detectedVendorKeys) {
119
123
  * vendor/scope expansion happens in `walkDetectedPlacements`.
120
124
  *
121
125
  * @param {string} target - PlacementTarget enum value.
126
+ * @param {string} [baseDir] - Project-scope anchor (#2672), threaded to
127
+ * `resolvePlacementRoot`. Omitted → cwd-anchored (unchanged).
122
128
  * @returns {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]}
123
129
  */
124
- export function walkPlacementTarget(target) {
130
+ export function walkPlacementTarget(target, baseDir) {
125
131
  let parentDir;
126
132
  try {
127
- parentDir = resolvePlacementRoot(target);
133
+ parentDir = resolvePlacementRoot(target, baseDir);
128
134
  } catch {
129
135
  // Unknown target — surface as no placements rather than throwing.
130
136
  // Defensive: detect-agents + agent-registry should never produce
package/src/lib/sync.mjs CHANGED
@@ -253,6 +253,7 @@ import {
253
253
  normalizeStateKey,
254
254
  receiptViolationSummary,
255
255
  formatRepoComplianceSummary,
256
+ buildRepoCompliance,
256
257
  } from "./foreign-content.mjs";
257
258
  import {
258
259
  scanGlobalBoundary,
@@ -2738,36 +2739,23 @@ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMo
2738
2739
  * @returns {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}}
2739
2740
  */
2740
2741
  function composeRepoCompliance({ skillsetRef, foreignCount, gbScan, resolved, stderr, hookMode }) {
2741
- // Bucket the served-but-not-written entries by their recorded reason
2742
- // (#2665 review): "modified" is the only bucket allowed to claim a
2743
- // local edit. "unmanaged" (a hand-authored dir squatting a member's
2744
- // name invisible to the foreign scan because the name IS managed)
2745
- // gets its own bucket rather than folding into `unmanagedCount`, so
2746
- // the state line's extras count stays consistent with the receipt's
2747
- // scan-derived `unmanaged` violation count. Everything else
2748
- // "incomplete", "invalid", and entries from older CLIs/state files
2749
- // that carry no reason — reads as the neutral "not delivered as
2750
- // approved", never as an edit accusation.
2751
- let editRefusedCount = 0;
2752
- let memberReplacedCount = 0;
2753
- let notDeliveredCount = 0;
2754
- for (const e of resolved ?? []) {
2755
- if (!e || e.written === true) continue;
2756
- if (e.unwrittenReason === "modified") editRefusedCount += 1;
2757
- else if (e.unwrittenReason === "unmanaged") memberReplacedCount += 1;
2758
- else notDeliveredCount += 1;
2759
- }
2760
- const compliance = {
2742
+ // The served-but-not-written bucketing (#2665 review "modified" is
2743
+ // the only bucket allowed to claim a local edit; "unmanaged" gets its
2744
+ // own bucket so the state line's extras count stays consistent with
2745
+ // the receipt's scan-derived `unmanaged`; everything else reads as the
2746
+ // neutral "not delivered as approved") lives in `buildRepoCompliance`
2747
+ // so `list` (#2672) composes the identical object read-only. The
2748
+ // scan-derived counts are extracted from THIS sync's fresh gbScan here,
2749
+ // where the global-boundary category constants are already imported.
2750
+ const compliance = buildRepoCompliance({
2761
2751
  skillsetRef,
2762
- unmanagedCount: foreignCount ?? 0,
2752
+ foreignCount,
2763
2753
  shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
2764
2754
  globalBeyondCount:
2765
2755
  (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
2766
2756
  (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
2767
- editRefusedCount,
2768
- memberReplacedCount,
2769
- notDeliveredCount,
2770
- };
2757
+ resolved,
2758
+ });
2771
2759
  if (!hookMode) {
2772
2760
  try {
2773
2761
  const line = formatRepoComplianceSummary(compliance);
@@ -16,7 +16,7 @@ import { join } from "node:path";
16
16
  import { tmpdir } from "node:os";
17
17
 
18
18
  import { runList } from "../../commands/list.mjs";
19
- import { CliError, EXIT_AUTH, EXIT_NETWORK } from "../../lib/errors.mjs";
19
+ import { CliError, EXIT_AUTH, EXIT_NETWORK, EXIT_UNRESOLVABLE } from "../../lib/errors.mjs";
20
20
  import { computeSkillShas } from "../../lib/crypto-shas.mjs";
21
21
  import { globalLastSyncPath } from "../../lib/paths.mjs";
22
22
  import { writeRepoSyncState } from "../../lib/repo-sync-state.mjs";
@@ -1400,3 +1400,476 @@ describe("runList — global-boundary disclosure (#2495)", () => {
1400
1400
  );
1401
1401
  });
1402
1402
  });
1403
+
1404
+ // ── Skillset-declared view (#2672) ─────────────────────────────────────
1405
+ //
1406
+ // In a repo whose skillrepo.json DECLARES a skillset, `list` shows that
1407
+ // repo's DELIVERED set (the per-repo state file's `resolved` manifest),
1408
+ // its per-row drift against the per-repo SHA baselines at the DETECTED
1409
+ // ROOT, and the #2665 compliance line — never the whole library. The
1410
+ // capture stream is non-TTY, so status cells render as ASCII tokens
1411
+ // (OK / EDIT / MISS / NOT DELIVERED), matching the library-view tests.
1412
+ describe("runList — skillset-declared view (#2672)", () => {
1413
+ beforeEach(async () => {
1414
+ await setup();
1415
+ forceClaudeCodeDetected();
1416
+ });
1417
+ afterEach(async () => {
1418
+ unforceDetection();
1419
+ await teardown();
1420
+ });
1421
+
1422
+ /** Write a DECLARING skillrepo.json (+ .git walk boundary) into cwd. */
1423
+ function declareSkillset({ name = "checkout", use = "acme/backend-core", extra } = {}) {
1424
+ mkdirSync(join(process.cwd(), ".git"), { recursive: true });
1425
+ const block = { version: 1, name, use, ...(extra ? { extra } : {}) };
1426
+ writeFileSync(join(process.cwd(), "skillrepo.json"), JSON.stringify({ skillset: block }));
1427
+ }
1428
+
1429
+ /** A written:true resolved entry whose baseline SHAs match `files` on disk. */
1430
+ function deliveredMember(owner, name, version, files) {
1431
+ const shas = seedClaudeSkillOnDisk(name, files);
1432
+ return {
1433
+ owner,
1434
+ name,
1435
+ version,
1436
+ written: true,
1437
+ skillMdSha256: shas.skillMdSha256,
1438
+ filesSha256: shas.filesSha256,
1439
+ };
1440
+ }
1441
+
1442
+ it("shows the delivered set under the skillset header, never the whole library", async () => {
1443
+ declareSkillset();
1444
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1445
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1446
+ ]);
1447
+ writeRepoSyncState(process.cwd(), {
1448
+ repoName: "checkout",
1449
+ skillsetRef: "acme/backend-core",
1450
+ resolved: [member],
1451
+ });
1452
+ // The library the skillset view must NOT show.
1453
+ server.setLibraryResponse({
1454
+ skills: [makeSkill("alice", "pdf-helper")],
1455
+ removals: [],
1456
+ syncedAt: "x",
1457
+ });
1458
+
1459
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1460
+ const out = stdout.text();
1461
+ assert.match(out, /Skillset: acme\/backend-core \(declared as checkout\)/);
1462
+ assert.match(out, /acme\/deploy/);
1463
+ assert.match(out, /1 member delivered and current/);
1464
+ assert.match(out, /\[ok\] compliant with skillset acme\/backend-core/);
1465
+ assert.ok(!out.includes("pdf-helper"), "the whole library is never shown in a declared repo");
1466
+ });
1467
+
1468
+ it("is fully offline and read-only: no library GET, no receipt, no state writes", async () => {
1469
+ const { existsSync } = await import("node:fs");
1470
+ const { globalGovernanceSeenPath } = await import("../../lib/paths.mjs");
1471
+ declareSkillset();
1472
+ writeRepoSyncState(process.cwd(), {
1473
+ repoName: "checkout",
1474
+ skillsetRef: "acme/backend-core",
1475
+ resolved: [
1476
+ deliveredMember("acme", "deploy", "1.0.0", [
1477
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1478
+ ]),
1479
+ ],
1480
+ });
1481
+
1482
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1483
+
1484
+ assert.equal(server.getLibraryRequestCount(), 0, "skillset view must not hit the library endpoint");
1485
+ assert.equal(server.getReceiptRequestCount(), 0, "list never writes a receipt");
1486
+ assert.equal(existsSync(globalLastSyncPath()), false, "list must not create .last-sync");
1487
+ assert.equal(existsSync(globalGovernanceSeenPath()), false, "list must not write governance-seen");
1488
+ });
1489
+
1490
+ it("leaves the per-repo state file byte-unchanged (read-only, hardened)", async () => {
1491
+ const { readFileSync } = await import("node:fs");
1492
+ const { repoStatePathFor } = await import("../../lib/repo-sync-state.mjs");
1493
+ declareSkillset();
1494
+ writeRepoSyncState(process.cwd(), {
1495
+ repoName: "checkout",
1496
+ skillsetRef: "acme/backend-core",
1497
+ resolved: [
1498
+ deliveredMember("acme", "deploy", "1.0.0", [
1499
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1500
+ ]),
1501
+ ],
1502
+ });
1503
+ const statePath = repoStatePathFor(process.cwd());
1504
+ const before = readFileSync(statePath, "utf8");
1505
+
1506
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1507
+
1508
+ assert.equal(
1509
+ readFileSync(statePath, "utf8"),
1510
+ before,
1511
+ "list must never rewrite the per-repo state file it reads",
1512
+ );
1513
+ });
1514
+
1515
+ it("classifies delivered members: current / edited / missing (against the per-repo baseline)", async () => {
1516
+ declareSkillset();
1517
+ // current — on-disk content matches the recorded baseline.
1518
+ const current = deliveredMember("acme", "keep", "1.0.0", [
1519
+ { path: "SKILL.md", content: SKILL_MD_BODY("keep") },
1520
+ ]);
1521
+ // edited — on disk, but the baseline SHAs are for different content.
1522
+ seedClaudeSkillOnDisk("touched", [{ path: "SKILL.md", content: SKILL_MD_BODY("touched-now") }]);
1523
+ const original = computeSkillShas([
1524
+ { path: "SKILL.md", content: SKILL_MD_BODY("touched-original") },
1525
+ ]);
1526
+ const edited = {
1527
+ owner: "acme",
1528
+ name: "touched",
1529
+ version: "1.0.0",
1530
+ written: true,
1531
+ skillMdSha256: original.skillMdSha256,
1532
+ filesSha256: original.filesSha256,
1533
+ };
1534
+ // missing — a written baseline but nothing on disk.
1535
+ const missing = deliveredMember("acme", "gone", "1.0.0", [
1536
+ { path: "SKILL.md", content: SKILL_MD_BODY("gone") },
1537
+ ]);
1538
+ rmSync(join(process.cwd(), ".claude", "skills", "gone"), { recursive: true, force: true });
1539
+
1540
+ writeRepoSyncState(process.cwd(), {
1541
+ repoName: "checkout",
1542
+ skillsetRef: "acme/backend-core",
1543
+ resolved: [current, edited, missing],
1544
+ });
1545
+
1546
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1547
+ const out = stdout.text();
1548
+ // Bind each status to ITS member (same table row — names are short
1549
+ // enough not to wrap), so a status-swap regression can't pass.
1550
+ assert.match(out, /@acme\/keep[^\n]*OK/);
1551
+ assert.match(out, /@acme\/touched[^\n]*EDIT/);
1552
+ assert.match(out, /@acme\/gone[^\n]*MISS/);
1553
+ assert.match(out, /3 members in skillset acme\/backend-core\. 2 need attention/);
1554
+ });
1555
+
1556
+ it("escapes control characters in a state-file-sourced member name (no terminal injection)", async () => {
1557
+ declareSkillset();
1558
+ // A corrupt / tampered per-repo state file with a raw ANSI escape in
1559
+ // a member name — the CLI must not emit it to the terminal verbatim.
1560
+ writeRepoSyncState(process.cwd(), {
1561
+ repoName: "checkout",
1562
+ skillsetRef: "acme/backend-core",
1563
+ resolved: [
1564
+ { owner: "acme", name: "deploy\u001b[31mFAKE", version: "1.0.0", written: false, unwrittenReason: "invalid" },
1565
+ ],
1566
+ });
1567
+
1568
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1569
+ const out = stdout.text();
1570
+ // Assert on the ATTACKER's payload specifically, not "any ESC byte":
1571
+ // cli-table3 emits its own ANSI for the table border/header when colors
1572
+ // are enabled (e.g. on Windows CI), so a blanket !includes(ESC) is a
1573
+ // false positive there. The injected raw ESC[31mFAKE must not survive...
1574
+ assert.ok(!out.includes("\u001b[31mFAKE"), "the injected raw ANSI sequence must never reach stdout");
1575
+ // ...it is rendered as its escaped form instead.
1576
+ assert.ok(out.includes("\\u001b[31mFAKE"), "the injected control char is rendered escaped");
1577
+ assert.match(out, /deploy/, "the (escaped) name is still shown");
1578
+ });
1579
+
1580
+ it("re-scans project foreign content: an extra dir drives unmanagedCount + non-compliance (human + json)", async () => {
1581
+ declareSkillset();
1582
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1583
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1584
+ ]);
1585
+ // A non-member directory squatting in the managed placement root.
1586
+ seedClaudeSkillOnDisk("rogue", [{ path: "SKILL.md", content: SKILL_MD_BODY("rogue") }]);
1587
+ writeRepoSyncState(process.cwd(), {
1588
+ repoName: "checkout",
1589
+ skillsetRef: "acme/backend-core",
1590
+ resolved: [member],
1591
+ });
1592
+
1593
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1594
+ const out = stdout.text();
1595
+ assert.match(out, /1 extra skill in the project/);
1596
+ assert.match(out, /not compliant to your organization/);
1597
+
1598
+ const jsonStdout = createCaptureStream();
1599
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout: jsonStdout });
1600
+ const parsed = JSON.parse(jsonStdout.text());
1601
+ assert.equal(parsed.skillset.compliance.unmanagedCount, 1, "the fresh foreign re-scan reaches the JSON compliance");
1602
+ assert.equal(parsed.skillset.compliant, false);
1603
+ });
1604
+
1605
+ it("re-scans global content: a shadowing global copy drives shadowedCount + non-compliance", async () => {
1606
+ declareSkillset();
1607
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1608
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1609
+ ]);
1610
+ // A global-scope dir with the SAME name as a member → global_shadowed.
1611
+ const globalDir = join(process.env.HOME, ".claude", "skills", "deploy");
1612
+ mkdirSync(globalDir, { recursive: true });
1613
+ writeFileSync(join(globalDir, "SKILL.md"), SKILL_MD_BODY("deploy-global"));
1614
+ writeRepoSyncState(process.cwd(), {
1615
+ repoName: "checkout",
1616
+ skillsetRef: "acme/backend-core",
1617
+ resolved: [member],
1618
+ });
1619
+
1620
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1621
+ const out = stdout.text();
1622
+ assert.match(out, /shadowed by a global copy/);
1623
+ assert.match(out, /not compliant to your organization/);
1624
+
1625
+ const jsonStdout = createCaptureStream();
1626
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout: jsonStdout });
1627
+ const parsed = JSON.parse(jsonStdout.text());
1628
+ assert.ok(parsed.skillset.compliance.shadowedCount >= 1, "the fresh global re-scan reaches the JSON compliance");
1629
+ assert.equal(parsed.skillset.compliant, false);
1630
+ });
1631
+
1632
+ it("renders a served-but-not-written member as a 'not delivered' row and flags non-compliance", async () => {
1633
+ declareSkillset();
1634
+ writeRepoSyncState(process.cwd(), {
1635
+ repoName: "checkout",
1636
+ skillsetRef: "acme/backend-core",
1637
+ resolved: [
1638
+ { owner: "acme", name: "locked", version: "1.0.0", written: false, unwrittenReason: "modified" },
1639
+ ],
1640
+ });
1641
+
1642
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1643
+ const out = stdout.text();
1644
+ assert.match(out, /NOT DELIVERED · edited locally/);
1645
+ assert.match(
1646
+ out,
1647
+ /warning: this repo does not match its skillset acme\/backend-core: 1 member edited locally\. It is reported as not compliant to your organization\./,
1648
+ );
1649
+ });
1650
+
1651
+ it("buckets not-delivered reasons (replaced / not-delivered) in the status cells and compliance line", async () => {
1652
+ declareSkillset();
1653
+ writeRepoSyncState(process.cwd(), {
1654
+ repoName: "checkout",
1655
+ skillsetRef: "acme/backend-core",
1656
+ resolved: [
1657
+ { owner: "acme", name: "squatted", version: "1.0.0", written: false, unwrittenReason: "unmanaged" },
1658
+ { owner: "acme", name: "partial", version: "1.0.0", written: false, unwrittenReason: "incomplete" },
1659
+ { owner: "acme", name: "bad", version: "1.0.0", written: false, unwrittenReason: "invalid" },
1660
+ { owner: "acme", name: "legacy", version: "1.0.0", written: false },
1661
+ ],
1662
+ });
1663
+
1664
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1665
+ const out = stdout.text();
1666
+ assert.match(out, /replaced/);
1667
+ assert.match(out, /incomplete/);
1668
+ assert.match(out, /invalid/);
1669
+ assert.match(
1670
+ out,
1671
+ /1 member replaced by unmanaged content, 3 members not delivered as approved/,
1672
+ );
1673
+ });
1674
+
1675
+ it("declared but never scoped-synced (no state file): points at update, never the library view", async () => {
1676
+ declareSkillset();
1677
+ server.setLibraryResponse({
1678
+ skills: [makeSkill("alice", "pdf-helper")],
1679
+ removals: [],
1680
+ syncedAt: "x",
1681
+ });
1682
+
1683
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1684
+ const out = stdout.text();
1685
+ assert.match(out, /This repo declares skillset acme\/backend-core, but there is no local sync state/);
1686
+ assert.match(out, /Run `skillrepo update`/);
1687
+ assert.ok(!out.includes("pdf-helper"), "never falls back to the library view (D9)");
1688
+ assert.equal(server.getLibraryRequestCount(), 0, "no state → still no library GET");
1689
+ });
1690
+
1691
+ it("declared, never-synced, --json: emits the synced:false skillset block, not a bare array", async () => {
1692
+ declareSkillset();
1693
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
1694
+ const parsed = JSON.parse(stdout.text());
1695
+ assert.ok(!Array.isArray(parsed), "declared repos emit a wrapped object");
1696
+ assert.equal(parsed.skillset.ref, "acme/backend-core");
1697
+ assert.equal(parsed.skillset.synced, false);
1698
+ assert.deepEqual(parsed.skills, []);
1699
+ });
1700
+
1701
+ it("an INVALID declaration fails closed (exit 6) and never shows the library", async () => {
1702
+ // A `use` with no slash is invalid → resolveDeclaration returns the
1703
+ // typed fail-closed error, exactly as sync surfaces it.
1704
+ declareSkillset({ use: "noslash" });
1705
+ server.setLibraryResponse({
1706
+ skills: [makeSkill("alice", "pdf-helper")],
1707
+ removals: [],
1708
+ syncedAt: "x",
1709
+ });
1710
+
1711
+ await assert.rejects(
1712
+ () => runList(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
1713
+ (err) => err instanceof CliError && err.exitCode === EXIT_UNRESOLVABLE,
1714
+ );
1715
+ assert.equal(server.getLibraryRequestCount(), 0, "a mis-declared repo is never shown the whole library");
1716
+ });
1717
+
1718
+ it("a config-only skillrepo.json (no skillset key) keeps the whole-library view", async () => {
1719
+ mkdirSync(join(process.cwd(), ".git"), { recursive: true });
1720
+ writeFileSync(join(process.cwd(), "skillrepo.json"), JSON.stringify({ someOtherConfig: true }));
1721
+ server.setLibraryResponse({
1722
+ skills: [makeSkill("alice", "pdf-helper")],
1723
+ removals: [],
1724
+ syncedAt: "x",
1725
+ });
1726
+
1727
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1728
+ const out = stdout.text();
1729
+ assert.ok(!out.includes("Skillset:"), "config-only is not a declaration");
1730
+ assert.match(out, /pdf-helper/);
1731
+ assert.equal(server.getLibraryRequestCount(), 1, "config-only still uses the library path");
1732
+ });
1733
+
1734
+ it("--json (declared + synced) is a wrapped object with machine-readable compliance", async () => {
1735
+ declareSkillset();
1736
+ const delivered = deliveredMember("acme", "deploy", "1.0.0", [
1737
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1738
+ ]);
1739
+ writeRepoSyncState(process.cwd(), {
1740
+ repoName: "checkout",
1741
+ skillsetRef: "acme/backend-core",
1742
+ resolved: [
1743
+ delivered,
1744
+ { owner: "acme", name: "locked", version: "2.0.0", written: false, unwrittenReason: "modified" },
1745
+ ],
1746
+ });
1747
+
1748
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
1749
+ const parsed = JSON.parse(stdout.text());
1750
+ assert.ok(!Array.isArray(parsed));
1751
+ assert.equal(parsed.skillset.ref, "acme/backend-core");
1752
+ assert.equal(parsed.skillset.synced, true);
1753
+ assert.equal(parsed.skillset.compliant, false, "an editRefused member is non-compliant");
1754
+ assert.equal(parsed.skillset.compliance.editRefusedCount, 1);
1755
+ const deployRow = parsed.skills.find((s) => s.name === "deploy");
1756
+ const lockedRow = parsed.skills.find((s) => s.name === "locked");
1757
+ assert.equal(deployRow.delivered, true);
1758
+ assert.equal(deployRow.state, "current");
1759
+ assert.equal(lockedRow.delivered, false);
1760
+ assert.equal(lockedRow.state, "not-delivered");
1761
+ assert.equal(lockedRow.reason, "modified");
1762
+ });
1763
+
1764
+ it("--json marks a fully-compliant repo compliant:true", async () => {
1765
+ declareSkillset();
1766
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1767
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1768
+ ]);
1769
+ writeRepoSyncState(process.cwd(), {
1770
+ repoName: "checkout",
1771
+ skillsetRef: "acme/backend-core",
1772
+ resolved: [member],
1773
+ });
1774
+
1775
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
1776
+ const parsed = JSON.parse(stdout.text());
1777
+ assert.equal(parsed.skillset.compliant, true);
1778
+ assert.equal(parsed.skills[0].state, "current");
1779
+ });
1780
+
1781
+ it("an empty skillset (zero resolved members) says so and is compliant", async () => {
1782
+ declareSkillset();
1783
+ writeRepoSyncState(process.cwd(), {
1784
+ repoName: "checkout",
1785
+ skillsetRef: "acme/backend-core",
1786
+ resolved: [],
1787
+ });
1788
+
1789
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1790
+ const out = stdout.text();
1791
+ assert.match(out, /Skillset acme\/backend-core resolves to no members/);
1792
+ assert.match(out, /\[ok\] compliant with skillset acme\/backend-core/);
1793
+ });
1794
+
1795
+ it("resolves by walking up and reads placements at the DETECTED ROOT, not cwd", async () => {
1796
+ // Declaration, state, and placements all live at the repo ROOT; the
1797
+ // command runs from a nested subdirectory. The pre-#2672 cwd walk
1798
+ // would find no placement (→ missing); the detected-root walk finds
1799
+ // it (→ current). Detection is env-forced, so it survives the chdir.
1800
+ declareSkillset();
1801
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1802
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1803
+ ]);
1804
+ const root = process.cwd();
1805
+ writeRepoSyncState(root, {
1806
+ repoName: "checkout",
1807
+ skillsetRef: "acme/backend-core",
1808
+ resolved: [member],
1809
+ });
1810
+ const sub = join(root, "packages", "app");
1811
+ mkdirSync(sub, { recursive: true });
1812
+ process.chdir(sub);
1813
+ try {
1814
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1815
+ } finally {
1816
+ process.chdir(root);
1817
+ }
1818
+ const out = stdout.text();
1819
+ assert.match(out, /Skillset: acme\/backend-core/);
1820
+ assert.match(out, /1 member delivered and current/);
1821
+ });
1822
+
1823
+ it("declared repo with no detected agents: still the skillset view, member reads MISS", async () => {
1824
+ unforceDetection(); // no agent footprint on this machine/project
1825
+ declareSkillset();
1826
+ writeRepoSyncState(process.cwd(), {
1827
+ repoName: "checkout",
1828
+ skillsetRef: "acme/backend-core",
1829
+ resolved: [
1830
+ {
1831
+ owner: "acme",
1832
+ name: "deploy",
1833
+ version: "1.0.0",
1834
+ written: true,
1835
+ skillMdSha256: "a".repeat(64),
1836
+ filesSha256: "b".repeat(64),
1837
+ },
1838
+ ],
1839
+ });
1840
+
1841
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1842
+ const out = stdout.text();
1843
+ assert.match(out, /Skillset: acme\/backend-core/);
1844
+ assert.match(out, /No agents detected in this project/);
1845
+ // No detected vendor → nothing to compare against → missing.
1846
+ assert.match(out, /MISS/);
1847
+ });
1848
+
1849
+ it("renders glyphs on a TTY (delivered current + not-delivered)", async () => {
1850
+ const noColorSnapshot = process.env.NO_COLOR;
1851
+ delete process.env.NO_COLOR; // canUseGlyphs short-circuits false if set
1852
+ const ttyStdout = createTtyCaptureStream();
1853
+ try {
1854
+ declareSkillset();
1855
+ const member = deliveredMember("acme", "deploy", "1.0.0", [
1856
+ { path: "SKILL.md", content: SKILL_MD_BODY("deploy") },
1857
+ ]);
1858
+ writeRepoSyncState(process.cwd(), {
1859
+ repoName: "checkout",
1860
+ skillsetRef: "acme/backend-core",
1861
+ resolved: [
1862
+ member,
1863
+ { owner: "acme", name: "locked", version: "1.0.0", written: false, unwrittenReason: "modified" },
1864
+ ],
1865
+ });
1866
+
1867
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
1868
+ const out = ttyStdout.text();
1869
+ assert.match(out, /✓ current/);
1870
+ assert.match(out, /⊘ not delivered · edited locally/);
1871
+ } finally {
1872
+ if (noColorSnapshot !== undefined) process.env.NO_COLOR = noColorSnapshot;
1873
+ }
1874
+ });
1875
+ });
@@ -14,8 +14,9 @@ import { describe, it } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
  import { execFile } from "node:child_process";
16
16
  import { fileURLToPath } from "node:url";
17
- import { dirname, resolve } from "node:path";
18
- import { readFileSync } from "node:fs";
17
+ import { dirname, resolve, join } from "node:path";
18
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
22
  const CLI_BIN = resolve(__dirname, "../../bin/skillrepo.mjs");
@@ -32,6 +33,9 @@ const PKG_PATH = resolve(__dirname, "../../package.json");
32
33
  * prove "no global config" code paths fire correctly. Pass
33
34
  * SKILLREPO_ACCESS_KEY: "" explicitly to override an inherited
34
35
  * env var from the developer's shell.
36
+ * @param {string} [opts.cwd] - Working directory to spawn the binary in.
37
+ * Used by declaration-aware commands (they walk up from cwd to a
38
+ * `skillrepo.json`). Undefined → the test process's cwd (default).
35
39
  */
36
40
  function runCli(args = [], opts = {}) {
37
41
  return new Promise((resolve) => {
@@ -52,6 +56,7 @@ function runCli(args = [], opts = {}) {
52
56
  {
53
57
  encoding: "utf-8",
54
58
  timeout: 10_000,
59
+ cwd: opts.cwd,
55
60
  env: { ...baseEnv, ...(opts.env || {}) },
56
61
  },
57
62
  (err, stdout, stderr) => {
@@ -331,3 +336,31 @@ describe("dispatcher — init still works (PR1 keeps existing init untouched)",
331
336
  // Real init flow is exercised by src/test/e2e/cli-init.test.mjs
332
337
  // against the mock server. We don't duplicate that here.
333
338
  });
339
+
340
+ describe("dispatcher — skillset declaration fail-closed (#2672)", () => {
341
+ // Locks the D9 fail-closed contract at the PRODUCTION entry point:
342
+ // `list` in a repo whose skillrepo.json is a declaration but cannot be
343
+ // honored must exit 6 (EXIT_UNRESOLVABLE) through bin → runList → the
344
+ // dispatcher's CliError→process.exit mapping — not just at the function
345
+ // level. This is the one exit code that had no spawned-process coverage.
346
+ it("`skillrepo list` with an INVALID declaration exits 6 (never shows the library)", async () => {
347
+ const dir = mkdtempSync(join(tmpdir(), "cli-disp-2672-"));
348
+ try {
349
+ mkdirSync(join(dir, ".git"), { recursive: true });
350
+ // `use` with no slash is a fail-closed declaration error.
351
+ writeFileSync(
352
+ join(dir, "skillrepo.json"),
353
+ JSON.stringify({ skillset: { version: 1, name: "x", use: "noslash" } }),
354
+ );
355
+ // A key is required before the declaration gate is reached, so pass
356
+ // one via flag (beats the empty SKILLREPO_ACCESS_KEY in baseEnv).
357
+ const r = await runCli(["list", "--key", "sk_live_test"], { cwd: dir });
358
+ assert.equal(r.status, 6, "invalid declaration must fail closed with EXIT_UNRESOLVABLE");
359
+ assert.match(r.stderr + r.stdout, /must have the form "owner\/name"/);
360
+ // D9: the whole-library table is never rendered on an invalid decl.
361
+ assert.ok(!/in your library/.test(r.stdout));
362
+ } finally {
363
+ rmSync(dir, { recursive: true, force: true });
364
+ }
365
+ });
366
+ });