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/README.md +30 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +18 -4
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/errors.mjs +23 -0
- package/src/lib/file-write.mjs +47 -47
- package/src/lib/foreign-content.mjs +461 -0
- package/src/lib/gitignore-section.mjs +223 -0
- package/src/lib/mergers/gitignore.mjs +22 -65
- package/src/lib/paths.mjs +11 -0
- package/src/lib/removers/gitignore.mjs +38 -3
- package/src/lib/skillset-declaration.mjs +477 -0
- package/src/lib/sync.mjs +179 -0
- package/src/test/commands/add.test.mjs +24 -0
- package/src/test/commands/get.test.mjs +24 -0
- package/src/test/commands/init.test.mjs +37 -0
- package/src/test/commands/update.test.mjs +194 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -0
- package/src/test/lib/errors.test.mjs +28 -0
- package/src/test/lib/file-write.test.mjs +1 -1
- package/src/test/lib/foreign-content.test.mjs +522 -0
- package/src/test/lib/gitignore-section.test.mjs +213 -0
- package/src/test/lib/skillset-declaration.test.mjs +514 -0
- package/src/test/lib/sync.test.mjs +635 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
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
|
|
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
|