skillrepo 4.9.2 → 4.11.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/src/lib/sync.mjs CHANGED
@@ -15,6 +15,13 @@
15
15
  * direct write (avoids clock-skew on the `since` filter).
16
16
  * • PR3a's `remove` does NOT use this — it deletes locally directly
17
17
  * (avoids the ETag cache poisoning on tombstones, see #875).
18
+ * • Because `add`/`get`/`remove` bypass `runSync`, they also bypass
19
+ * the #2362 skillset-declaration gate below — DELIBERATE in H5:
20
+ * the declaration governs the ambient sync surface, not a hard
21
+ * per-repo ACL (D15: skillset curates, never authorizes). Whether
22
+ * scoped repos should gate or receipts-surface these commands is
23
+ * an open H8 decision, recorded on #2365 — do not "fix" the
24
+ * bypass here as a bug.
18
25
  * • PR3b's `init` calls `runSync({ vendors, global })` after writing
19
26
  * the global config to perform the first pull.
20
27
  *
@@ -99,6 +106,18 @@
99
106
  * Consumers must distinguish these to
100
107
  * render accurate user messages (see
101
108
  * init.mjs step 7).
109
+ * @property {number} [unmanaged] - Unmanaged (non-CLI-managed) skill dirs
110
+ * detected in the managed placement roots
111
+ * this sync writes to (#2361). "unmanaged"
112
+ * is the one wire word for this concept —
113
+ * same as the receipts category and the
114
+ * warning copy; the design doc's internal
115
+ * term is "foreign content". Warn-only
116
+ * observability — NOT part of the change
117
+ * totals the hook printers gate on, so a
118
+ * 304 with unmanaged content stays silent
119
+ * in hook modes. Absent on a throttled
120
+ * skip (detection doesn't run there).
102
121
  * @property {string} syncedAt - ISO timestamp of the sync: the server
103
122
  * response `syncedAt` on a 200, or the
104
123
  * previously-cached sync timestamp on a
@@ -152,6 +171,20 @@ import {
152
171
  EXIT_VALIDATION,
153
172
  } from "./errors.mjs";
154
173
  import { computeSkillShas } from "./crypto-shas.mjs";
174
+ import {
175
+ scanForeignContent,
176
+ formatForeignWarnings,
177
+ checkDeclarationGitignored,
178
+ formatDeclarationIgnoredWarning,
179
+ readGovernanceSeen,
180
+ commitGovernanceSeen,
181
+ selectNewFindings,
182
+ normalizeStateKey,
183
+ } from "./foreign-content.mjs";
184
+ import {
185
+ resolveDeclaration,
186
+ declarationUnavailableError,
187
+ } from "./skillset-declaration.mjs";
155
188
 
156
189
  /**
157
190
  * Current schema version of the .last-sync file. Bump on any
@@ -641,6 +674,12 @@ export function deleteLastSyncEntry(owner, name) {
641
674
  * (#2174): a hook-triggered sync (`throttle: true`) within
642
675
  * MIN_SYNC_INTERVAL_MS of the last attempt short-circuits HERE with
643
676
  * ZERO network calls. A bare interactive `update` never throttles.
677
+ * 1b. Skillset-declaration gate (#2362, project scope only): resolve
678
+ * the repo's `skillrepo.json` via walk-up; any found declaration
679
+ * fails the sync closed (typed EXIT 6) BEFORE any write, delete,
680
+ * or network call — invalid files with a precise schema error,
681
+ * well-formed ones with the pre-H8 "not yet available" error.
682
+ * No declaration → everything below is byte-identical (D2).
644
683
  * 2. Clean up any orphan .tmp/.old directories from a crashed prior run
645
684
  * 3. Call GET /api/v1/library with `If-None-Match` (if we have a
646
685
  * cached ETag) AND `since` (always, for delta semantics)
@@ -762,6 +801,39 @@ export async function runSync(options) {
762
801
  }
763
802
  }
764
803
 
804
+ // Skillset-declaration gate (#2362, D9). A repo that declares a
805
+ // skillset must NEVER receive whole-library delivery, and an
806
+ // invalid declaration must not sync at all — fail closed, before
807
+ // ANY write, delete, or network call (cleanupOrphans included: the
808
+ // contract is "nothing on disk changes"). Placement of this gate:
809
+ //
810
+ // • AFTER the throttle early-exit — a throttled hook sync does
811
+ // zero work of any kind (#2174), which trivially satisfies
812
+ // fail-closed, and keeps session starts free of filesystem
813
+ // walking. Interactive `update` is never throttled, so the
814
+ // error always surfaces there within one command.
815
+ // • Project-scoped syncs only. `--global` writes personal
816
+ // placements in the user's home, not this repo — the
817
+ // global-scope boundary is D20's territory (#2495 disclosure +
818
+ // receipts category 3), not the declaration's.
819
+ //
820
+ // Pre-H8 dark shipping: there is no client-side skillset
821
+ // resolution yet, so a WELL-FORMED declaration is unresolvable by
822
+ // definition and gets the "not yet available" error. H8 (#2365)
823
+ // replaces that throw with real resolution against the server.
824
+ // Repos with no declaration take the `absent`/`config-only` path —
825
+ // read-only probes, byte-identical wire behavior (D2).
826
+ let declarationResolution = null;
827
+ if (global !== true) {
828
+ declarationResolution = resolveDeclaration();
829
+ if (declarationResolution.status === "invalid") {
830
+ throw declarationResolution.error;
831
+ }
832
+ if (declarationResolution.status === "declared") {
833
+ throw declarationUnavailableError(declarationResolution);
834
+ }
835
+ }
836
+
765
837
  // Step 2: clean orphans from prior crashes BEFORE doing any new writes
766
838
  cleanupOrphans({ vendors, global });
767
839
 
@@ -913,12 +985,27 @@ export async function runSync(options) {
913
985
  `Next sync will not be throttled.\n`,
914
986
  );
915
987
  }
988
+ // Foreign-content detection runs on the 304 path too (#2361): the
989
+ // library being unchanged says nothing about what else sits in the
990
+ // managed roots, and a deliberately-stable library would otherwise
991
+ // never surface violations. The on-disk managed set on a 304 is
992
+ // exactly the prior `.last-sync` skills map.
993
+ const unmanaged = emitGovernanceWarnings({
994
+ vendors,
995
+ global,
996
+ managedSkills: lastSync?.skills,
997
+ stderr,
998
+ hookMode: throttle === true,
999
+ declarationDir: declarationResolution?.rootDir,
1000
+ });
1001
+
916
1002
  return {
917
1003
  added: 0,
918
1004
  updated: 0,
919
1005
  removed: 0,
920
1006
  notModified: true,
921
1007
  fullSync,
1008
+ unmanaged,
922
1009
  syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
923
1010
  };
924
1011
  }
@@ -1130,6 +1217,18 @@ export async function runSync(options) {
1130
1217
  }
1131
1218
  }
1132
1219
 
1220
+ // Foreign-content detection (#2361): after writes and tombstones, so
1221
+ // skills delivered THIS round are already in `skillsMap` and never
1222
+ // misclassified as unmanaged. Warn-only; never affects the sync result.
1223
+ summary.unmanaged = emitGovernanceWarnings({
1224
+ vendors,
1225
+ global,
1226
+ managedSkills: skillsMap,
1227
+ stderr,
1228
+ hookMode: throttle === true,
1229
+ declarationDir: declarationResolution?.rootDir,
1230
+ });
1231
+
1133
1232
  // Re-assert the full on-disk state via a sync receipt (#1832). This
1134
1233
  // confirmed-write signal — not the fetch itself — is what sets team
1135
1234
  // sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
@@ -1157,6 +1256,86 @@ export async function runSync(options) {
1157
1256
 
1158
1257
  // ── Internals ──────────────────────────────────────────────────────────
1159
1258
 
1259
+ /**
1260
+ * Run the sync-time governance checks and write their warnings to the
1261
+ * caller's stderr (#2361): foreign-content detection over the managed
1262
+ * roots this sync writes to, plus the gitignored-declaration probe.
1263
+ * Returns the unmanaged-dir count for the summary.
1264
+ *
1265
+ * Warn-only by construction — never throws, never changes sync
1266
+ * behavior. Foreign dir NAMES appear only in these local warning
1267
+ * lines; anything that leaves the machine goes through
1268
+ * `receiptViolationSummary` (counts/category only — the H7 #2364
1269
+ * seam; see foreign-content.mjs for the privacy contract).
1270
+ *
1271
+ * WARN-ON-NEW (owner directive, 2026-08-07): a warning that repeats
1272
+ * unchanged findings on every sync trains users to ignore it. The
1273
+ * already-warned set persists in `.governance-seen`; only NEW findings
1274
+ * print, and a removed-then-reappearing dir re-warns. The returned
1275
+ * count is always the TOTAL currently on disk (state observability),
1276
+ * not just the new ones.
1277
+ *
1278
+ * HOOK SYNCS (`hookMode: true` — the --session-hook / --silent
1279
+ * branches) neither print warnings nor mark findings as seen: the
1280
+ * user isn't looking at those streams, and recording seen there would
1281
+ * permanently swallow the warning before an interactive sync ever
1282
+ * showed it. Governance warnings are an interactive-surface feature;
1283
+ * hook syncs still contribute the count. The throttled early-exit
1284
+ * returns before this runs at all (zero-work contract).
1285
+ *
1286
+ * @param {object} options
1287
+ * @param {string[]} [options.vendors]
1288
+ * @param {boolean} [options.global]
1289
+ * @param {Record<string, unknown> | null | undefined} options.managedSkills
1290
+ * @param {NodeJS.WritableStream} options.stderr
1291
+ * @param {boolean} [options.hookMode] - True for hook-triggered syncs.
1292
+ * @param {string} [options.declarationDir] - The walk-up's detected
1293
+ * root (#2362): the directory holding the repo's
1294
+ * `skillrepo.json`, when one was found. The gitignored probe
1295
+ * and its warn-on-new key anchor HERE, not cwd, so a sync run
1296
+ * from a subdirectory still probes the actual file and two
1297
+ * subdirectories of one repo share one seen flag. Absent (no
1298
+ * file found, or a `--global` sync where the walk never ran) →
1299
+ * cwd, preserving the H4 behavior.
1300
+ * @returns {number} Unmanaged-dir count across scanned roots (the
1301
+ * summary's `unmanaged` field — total, not just new).
1302
+ */
1303
+ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode, declarationDir }) {
1304
+ let unmanagedCount = 0;
1305
+ try {
1306
+ const scan = scanForeignContent({ vendors, global, managedSkills });
1307
+ unmanagedCount = scan.foreignCount;
1308
+ if (hookMode) return unmanagedCount;
1309
+
1310
+ const seen = readGovernanceSeen();
1311
+ const newScan = selectNewFindings(scan, seen);
1312
+ for (const line of formatForeignWarnings(newScan)) {
1313
+ stderr.write(`${line}\n`);
1314
+ }
1315
+
1316
+ // Gitignored-declaration warning, gated the same way: once per
1317
+ // repo while the state holds; leaving the gitignored state re-arms
1318
+ // it. Keyed by the NORMALIZED repo path (realpath + win32
1319
+ // case-fold) so two spellings of one checkout share a flag.
1320
+ // Anchored at the walk-up's detected root when one exists (#2362).
1321
+ const probeDir = declarationDir ?? process.cwd();
1322
+ const repoKey = normalizeStateKey(probeDir);
1323
+ const decl = checkDeclarationGitignored({ cwd: probeDir });
1324
+ const declarationIgnored = decl.present && decl.ignored;
1325
+ if (declarationIgnored && !seen.declarations[repoKey]) {
1326
+ stderr.write(`${formatDeclarationIgnoredWarning()}\n`);
1327
+ }
1328
+
1329
+ // Narrow-merge commit: re-reads the file and rewrites ONLY this
1330
+ // sync's scanned roots + this repo's declaration flag, so a
1331
+ // concurrent sync in another repo keeps its update.
1332
+ commitGovernanceSeen({ scan, repoKey, declarationIgnored });
1333
+ } catch {
1334
+ // Detection is best-effort; a probe failure must never fail a sync.
1335
+ }
1336
+ return unmanagedCount;
1337
+ }
1338
+
1160
1339
  /**
1161
1340
  * Build sync-receipt entries from a `.last-sync` skills map (#1832).
1162
1341
  *
@@ -381,3 +381,27 @@ describe("runAdd — best-effort .last-sync write failure does not fail the comm
381
381
  );
382
382
  });
383
383
  });
384
+
385
+ // ── Skillset-declaration NON-gating (#2362 baseline lock) ─────────────
386
+
387
+ describe("runAdd — deliberately ungated by the skillset declaration (#2362)", () => {
388
+ beforeEach(setup);
389
+ afterEach(teardown);
390
+
391
+ it("a declared repo can still `add` a single skill (D15: curate, never authorize)", async () => {
392
+ // Same baseline-lock rationale as the runGet twin: the open H8
393
+ // decision on #2365 must change this deliberately, never silently.
394
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
395
+ writeFileSync(
396
+ join(sandbox, "project", "skillrepo.json"),
397
+ JSON.stringify({
398
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
399
+ }),
400
+ );
401
+ // Default mock answers the add POST with 201; register the skill
402
+ // for the follow-up GET, same as the happy-path test above.
403
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
404
+ await runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
405
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "pdf-helper")));
406
+ });
407
+ });
@@ -309,3 +309,27 @@ describe("runGet — best-effort .last-sync write failure does not fail the comm
309
309
  );
310
310
  });
311
311
  });
312
+
313
+ // ── Skillset-declaration NON-gating (#2362 baseline lock) ─────────────
314
+
315
+ describe("runGet — deliberately ungated by the skillset declaration (#2362)", () => {
316
+ beforeEach(setup);
317
+ afterEach(teardown);
318
+
319
+ it("a declared repo can still `get` a single skill (D15: curate, never authorize)", async () => {
320
+ // The declaration gates the ambient sync surface (runSync) only.
321
+ // Whether scoped repos should gate get/add is an OPEN H8 decision
322
+ // (#2365) — this test locks TODAY'S contract so H8 changes it
323
+ // deliberately, not by accident.
324
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
325
+ writeFileSync(
326
+ join(sandbox, "project", "skillrepo.json"),
327
+ JSON.stringify({
328
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
329
+ }),
330
+ );
331
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
332
+ await runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
333
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "pdf-helper")));
334
+ });
335
+ });
@@ -2401,3 +2401,40 @@ describe("directoryHasContent", () => {
2401
2401
  }
2402
2402
  });
2403
2403
  });
2404
+
2405
+ // ── Skillset-declaration awareness (#2362) ─────────────────────────────
2406
+
2407
+ describe("runInit — skillset-declaration awareness (#2362)", () => {
2408
+ beforeEach(setup);
2409
+ afterEach(teardown);
2410
+
2411
+ it("declared repo: init completes (exit path 0), config saved, tailored blocked-sync copy", async () => {
2412
+ mkdirSync(join(process.cwd(), ".git"), { recursive: true });
2413
+ writeFileSync(
2414
+ join(process.cwd(), "skillrepo.json"),
2415
+ JSON.stringify({
2416
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
2417
+ }),
2418
+ );
2419
+
2420
+ await runInit(
2421
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
2422
+ { stdout, stderr },
2423
+ );
2424
+
2425
+ // Config IS persisted — the sync gate must not fail the init.
2426
+ const cfg = readConfig();
2427
+ assert.ok(cfg, "config must be written even when the declaration gates the sync");
2428
+ assert.equal(cfg.apiKey, VALID_KEY);
2429
+
2430
+ const out = stdout.text();
2431
+ // Tailored copy: the generic "run update later to retry" advice is
2432
+ // wrong for a declaration-gated repo (retrying changes nothing).
2433
+ assert.match(out, /library sync is blocked/);
2434
+ assert.match(out, /not yet available/);
2435
+ assert.doesNotMatch(out, /Run `skillrepo update` later to retry/);
2436
+ // The what-to-do hint from the typed error is included.
2437
+ assert.match(out, /Keep the declaration/);
2438
+ assert.match(out, /SkillRepo is ready/);
2439
+ });
2440
+ });
@@ -261,6 +261,25 @@ describe("runUpdate — --session-hook contract", () => {
261
261
  );
262
262
  });
263
263
 
264
+ it("stays silent on a zero-change sync even when foreign content is present (#2361)", async () => {
265
+ // Foreign dirs are observability, not changes: the hook-mode
266
+ // output gate keys on added+updated+removed+skipped, and the
267
+ // foreign count must never trip it. Both streams are black-holed
268
+ // in this mode, so the warning lines vanish too — session start
269
+ // stays clean.
270
+ const { mkdirSync, writeFileSync } = await import("node:fs");
271
+ const { join } = await import("node:path");
272
+ const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
273
+ mkdirSync(dir, { recursive: true });
274
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
275
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
276
+ await runUpdate(
277
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
278
+ { stdout },
279
+ );
280
+ assert.equal(stdout.text(), "", "foreign content must not break hook silence");
281
+ });
282
+
264
283
  it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
265
284
  // INTENT: 304 means nothing changed. Printing "Syncing..." on
266
285
  // every session start with no value to show would clutter the
@@ -473,6 +492,38 @@ describe("runUpdate — --silent contract", () => {
473
492
  assert.equal(stdout.text(), "{}\n");
474
493
  });
475
494
 
495
+ it("emits no governance warning under --silent, and does not consume it (#2361)", async () => {
496
+ // Governance warnings are an interactive-surface feature: hook
497
+ // syncs print nothing (the user is not looking at vendor hook
498
+ // logs) and must NOT mark the finding as seen — the next
499
+ // interactive `update` is the first to warn. Stdout stays pure
500
+ // JSON either way (Gemini contract).
501
+ const { mkdirSync, writeFileSync } = await import("node:fs");
502
+ const { join } = await import("node:path");
503
+ const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
504
+ const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
505
+ mkdirSync(dir, { recursive: true });
506
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
507
+ const stderr = createCaptureStream();
508
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
509
+ await runUpdate(
510
+ ["--key", VALID_KEY, "--url", serverUrl, "--silent"],
511
+ { stdout, stderr },
512
+ );
513
+ assert.equal(stdout.text(), "{}\n", "stdout must stay pure JSON");
514
+ assert.ok(!stderr.text().includes("not from your library"), "hook sync must not warn");
515
+
516
+ // Interactive update afterwards: the warning is still available.
517
+ const stdout2 = createCaptureStream();
518
+ const stderr2 = createCaptureStream();
519
+ await runUpdate(
520
+ ["--key", VALID_KEY, "--url", serverUrl],
521
+ { stdout: stdout2, stderr: stderr2 },
522
+ );
523
+ assert.match(stderr2.text(), /hand-rolled/);
524
+ assert.match(stderr2.text(), /skillrepo push/);
525
+ });
526
+
476
527
  it("emits `{}\\n` even when the sync produced changes (no progress lines on stdout)", async () => {
477
528
  server.setLibraryResponse({
478
529
  skills: [makeSkill("silent-test")],
@@ -855,3 +906,146 @@ describe("runUpdate — SessionStart throttle (#2174)", () => {
855
906
  assert.deepEqual(after.skills, prior.skills, "per-skill SHA baseline preserved");
856
907
  });
857
908
  });
909
+
910
+ // ── Skillset-declaration gate through the update command (#2362) ───────
911
+
912
+ describe("runUpdate — skillset-declaration gate (#2362)", () => {
913
+ beforeEach(setup);
914
+ afterEach(teardown);
915
+
916
+ function writeDeclaredRepo() {
917
+ // .git bounds the walk inside the sandbox project dir.
918
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
919
+ writeFileSync(
920
+ join(sandbox, "project", "skillrepo.json"),
921
+ JSON.stringify({
922
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
923
+ }),
924
+ );
925
+ }
926
+
927
+ it("normal mode: rejects with EXIT 6 and the not-yet-available copy", async () => {
928
+ writeDeclaredRepo();
929
+ await assert.rejects(
930
+ runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
931
+ (err) => {
932
+ assert.ok(err instanceof CliError);
933
+ assert.equal(err.exitCode, 6);
934
+ assert.match(err.message, /not yet available/);
935
+ assert.match(err.hint, /Keep the declaration/);
936
+ return true;
937
+ },
938
+ );
939
+ });
940
+
941
+ it("--session-hook: exits cleanly with the one-line failure message (contract: exit 0)", async () => {
942
+ writeDeclaredRepo();
943
+ // runUpdate must RESOLVE (the session-hook contract catches every
944
+ // error); the failure surfaces as the single stdout line.
945
+ await runUpdate(
946
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
947
+ { stdout },
948
+ );
949
+ const out = stdout.text();
950
+ assert.match(out, /^\[SkillRepo\] Sync failed: /);
951
+ assert.match(out, /not yet available/);
952
+ assert.equal(out.trim().split("\n").length, 1, "exactly one output line");
953
+ });
954
+
955
+ it("--silent: propagates the typed error (cohort hooks surface real exit codes)", async () => {
956
+ writeDeclaredRepo();
957
+ await assert.rejects(
958
+ runUpdate(["--silent", "--key", VALID_KEY, "--url", serverUrl], { stdout }),
959
+ (err) => err instanceof CliError && err.exitCode === 6,
960
+ );
961
+ assert.equal(stdout.text(), "", "no {} success line on failure");
962
+ });
963
+
964
+ it("invalid declaration through update: names the problem field", async () => {
965
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
966
+ writeFileSync(
967
+ join(sandbox, "project", "skillrepo.json"),
968
+ JSON.stringify({ skillset: { version: 1, name: "checkout", use: "acme/x@2" } }),
969
+ );
970
+ await assert.rejects(
971
+ runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
972
+ (err) => err.exitCode === 6 && /not yet supported/.test(err.message),
973
+ );
974
+ });
975
+ });
976
+
977
+ // ── Throttle window + declaration interplay through the hook flags ─────
978
+ // (#2362 prod-readiness QA): a throttled hook sync returns BEFORE the
979
+ // gate by documented design (#2174 zero-work contract). A repo that
980
+ // synced successfully, then gained a declaration, stays SILENT in hook
981
+ // modes until the window lapses — intentional, pinned here so it can
982
+ // never be mistaken for (or regress into) a masked failure. Interactive
983
+ // `update` is never throttled, so the error always surfaces there.
984
+
985
+ describe("runUpdate — throttled hook syncs precede the declaration gate (#2362)", () => {
986
+ beforeEach(setup);
987
+ afterEach(teardown);
988
+
989
+ function armThrottleThenDeclare() {
990
+ writeLastSync({
991
+ etag: '"e1"',
992
+ syncedAt: new Date().toISOString(),
993
+ skills: {},
994
+ lastAttemptAt: new Date().toISOString(),
995
+ });
996
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
997
+ writeFileSync(
998
+ join(sandbox, "project", "skillrepo.json"),
999
+ JSON.stringify({
1000
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
1001
+ }),
1002
+ );
1003
+ }
1004
+
1005
+ it("--silent within the window: '{}' success line, zero network", async () => {
1006
+ armThrottleThenDeclare();
1007
+ await runUpdate(["--silent", "--key", VALID_KEY, "--url", serverUrl], { stdout });
1008
+ assert.equal(stdout.text(), "{}\n");
1009
+ assert.equal(server.getLibraryRequestCount(), 0);
1010
+ });
1011
+
1012
+ it("--session-hook within the window: no output at all, zero network", async () => {
1013
+ armThrottleThenDeclare();
1014
+ await runUpdate(["--session-hook", "--key", VALID_KEY, "--url", serverUrl], { stdout });
1015
+ assert.equal(stdout.text(), "");
1016
+ assert.equal(server.getLibraryRequestCount(), 0);
1017
+ });
1018
+
1019
+ it("interactive update ignores the window and surfaces the gate error", async () => {
1020
+ armThrottleThenDeclare();
1021
+ await assert.rejects(
1022
+ runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
1023
+ (err) => err instanceof CliError && err.exitCode === 6,
1024
+ );
1025
+ });
1026
+ });
1027
+
1028
+ // ── --json + declared repo: pin the CURRENT error contract (#2362) ─────
1029
+
1030
+ describe("runUpdate — --json with a declared repo (#2362)", () => {
1031
+ beforeEach(setup);
1032
+ afterEach(teardown);
1033
+
1034
+ it("rejects with the typed exit-6 error and writes NO stdout (no JSON error envelope today)", async () => {
1035
+ // The dispatcher's catch prints plain text to stderr regardless of
1036
+ // --json — a systemic CLI contract, not #2362-specific. Pinned so
1037
+ // a structured-JSON error surface is a deliberate future change.
1038
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
1039
+ writeFileSync(
1040
+ join(sandbox, "project", "skillrepo.json"),
1041
+ JSON.stringify({
1042
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
1043
+ }),
1044
+ );
1045
+ await assert.rejects(
1046
+ runUpdate(["--json", "--key", VALID_KEY, "--url", serverUrl], { stdout }),
1047
+ (err) => err instanceof CliError && err.exitCode === 6,
1048
+ );
1049
+ assert.equal(stdout.text(), "", "no partial JSON on the failure path");
1050
+ });
1051
+ });
@@ -122,6 +122,69 @@ describe("CLI E2E — read commands", () => {
122
122
  assert.match(r.stdout, /up to date/);
123
123
  });
124
124
 
125
+ it("update warns about a foreign skill dir on stderr and exits 0 (#2361)", async () => {
126
+ const { writeFileSync } = await import("node:fs");
127
+ // Hand-authored dir the CLI never wrote — a governance violation,
128
+ // surfaced but never touched.
129
+ mkdirSync(join(tempDir, ".claude", "skills", "hand-rolled"), { recursive: true });
130
+ writeFileSync(
131
+ join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md"),
132
+ "---\nname: hand-rolled\n---\n",
133
+ );
134
+ server.setLibraryResponse({
135
+ skills: [makeSkill("alice", "pdf-helper")],
136
+ removals: [],
137
+ syncedAt: "x",
138
+ });
139
+ const r = await runCli([
140
+ "update",
141
+ "--key", VALID_KEY,
142
+ "--url", `http://127.0.0.1:${port}`,
143
+ ]);
144
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
145
+ assert.match(r.stderr, /is not from your library/);
146
+ assert.match(r.stderr, /skillrepo push/);
147
+ assert.match(r.stderr, /\.claude\/skills\/hand-rolled\//);
148
+ // The foreign dir is surfaced, never destroyed.
149
+ assert.ok(existsSync(join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md")));
150
+ // The synced skill is managed — not warned about.
151
+ assert.ok(!r.stderr.includes("pdf-helper"));
152
+
153
+ // Warn-on-new: an identical second run repeats nothing.
154
+ const r2 = await runCli([
155
+ "update",
156
+ "--key", VALID_KEY,
157
+ "--url", `http://127.0.0.1:${port}`,
158
+ ]);
159
+ assert.equal(r2.status, 0, `stderr: ${r2.stderr}`);
160
+ assert.ok(
161
+ !r2.stderr.includes("hand-rolled"),
162
+ "unchanged finding must not re-warn on the next run",
163
+ );
164
+ });
165
+
166
+ it("update --json carries the unmanaged count (#2361)", async () => {
167
+ const { writeFileSync } = await import("node:fs");
168
+ mkdirSync(join(tempDir, ".claude", "skills", "rogue"), { recursive: true });
169
+ writeFileSync(
170
+ join(tempDir, ".claude", "skills", "rogue", "SKILL.md"),
171
+ "---\nname: rogue\n---\n",
172
+ );
173
+ server.setLibraryResponse({
174
+ skills: [makeSkill("alice", "pdf-helper")],
175
+ removals: [],
176
+ syncedAt: "x",
177
+ });
178
+ const r = await runCli([
179
+ "update", "--json",
180
+ "--key", VALID_KEY,
181
+ "--url", `http://127.0.0.1:${port}`,
182
+ ]);
183
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
184
+ const json = JSON.parse(r.stdout);
185
+ assert.equal(json.unmanaged, 1);
186
+ });
187
+
125
188
  it("update writes a single skill and prints the summary", async () => {
126
189
  server.setLibraryResponse({
127
190
  skills: [makeSkill("alice", "pdf-helper")],
@@ -12,12 +12,14 @@ import {
12
12
  EXIT_DISK,
13
13
  EXIT_SCOPE,
14
14
  EXIT_VALIDATION,
15
+ EXIT_UNRESOLVABLE,
15
16
  CliError,
16
17
  networkError,
17
18
  authError,
18
19
  diskError,
19
20
  scopeError,
20
21
  validationError,
22
+ unresolvableError,
21
23
  isRetryable,
22
24
  computeBackoffDelay,
23
25
  withRetry,
@@ -357,3 +359,29 @@ describe("errors.mjs — withRetry", () => {
357
359
  );
358
360
  });
359
361
  });
362
+
363
+ // ── EXIT_UNRESOLVABLE (#2362) ──────────────────────────────────────────
364
+
365
+ describe("unresolvableError (#2362)", () => {
366
+ it("EXIT_UNRESOLVABLE is 6 and distinct from every prior code", () => {
367
+ assert.equal(EXIT_UNRESOLVABLE, 6);
368
+ const prior = [EXIT_OK, EXIT_NETWORK, EXIT_AUTH, EXIT_DISK, EXIT_SCOPE, EXIT_VALIDATION];
369
+ assert.ok(!prior.includes(EXIT_UNRESOLVABLE));
370
+ });
371
+
372
+ it("constructor carries the exit code, hint, and cause", () => {
373
+ const cause = new Error("inner");
374
+ const err = unresolvableError("declaration cannot be honored", {
375
+ hint: "fix or remove skillrepo.json",
376
+ cause,
377
+ });
378
+ assert.ok(err instanceof CliError);
379
+ assert.equal(err.exitCode, EXIT_UNRESOLVABLE);
380
+ assert.equal(err.hint, "fix or remove skillrepo.json");
381
+ assert.equal(err.cause, cause);
382
+ });
383
+
384
+ it("is never retryable — a declaration problem never succeeds on retry", () => {
385
+ assert.equal(isRetryable(unresolvableError("nope")), false);
386
+ });
387
+ });
@@ -956,7 +956,7 @@ describe("ensureFallbackGitignore — error surfacing", () => {
956
956
  });
957
957
 
958
958
  it("throws diskError when .gitignore cannot be READ (it is a directory → EISDIR)", () => {
959
- // The read-error branch in ensureAgentsGitignore: readFileSafe
959
+ // The read-error branch in the gitignore-section core: readFileSafe
960
960
  // re-throws any non-ENOENT error. Making `.gitignore` a DIRECTORY
961
961
  // causes readFileSync to throw EISDIR, which surfaces as a typed
962
962
  // diskError with a "Cannot read" message before any write is