skillrepo 4.7.0 → 4.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +12 -2
- package/src/lib/sync.mjs +124 -8
- package/src/test/commands/add.test.mjs +78 -1
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +133 -0
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/mock-server.mjs +19 -11
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +615 -0
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +434 -0
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
package/bin/skillrepo.mjs
CHANGED
|
@@ -70,9 +70,12 @@ const COMMANDS = {
|
|
|
70
70
|
},
|
|
71
71
|
push: {
|
|
72
72
|
description: "Push a local skill directory to your library (create or release new version)",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Versioning is server-side (frontmatter-driven smart upsert), so the CLI
|
|
74
|
+
// does NOT accept --version/--changelog — they were removed in the #1455
|
|
75
|
+
// push rework. Do not re-add them here without wiring them into push.mjs:
|
|
76
|
+
// the advertised-surface contract test asserts every flag listed in this
|
|
77
|
+
// string is actually accepted by the command (#1923).
|
|
78
|
+
usage: "skillrepo push <path> [--idempotency-key <key>] [--json]",
|
|
76
79
|
run: async (argv) => runPush(argv),
|
|
77
80
|
},
|
|
78
81
|
publish: {
|
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -199,13 +199,23 @@ const BLACK_HOLE_STREAM = {
|
|
|
199
199
|
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
200
200
|
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
201
201
|
* @param {object} [deps] - Test-only dependency injection. Production
|
|
202
|
-
* callers leave this empty.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
202
|
+
* callers leave this empty. `promptFn` (below) is consumed locally;
|
|
203
|
+
* the remaining keys are forwarded to step 6
|
|
204
|
+
* (`installSessionSyncHook`) — see `init-session-sync.mjs` for
|
|
205
|
+
* those (`spawn`, `getCliVersion`, `confirmFn`).
|
|
206
|
+
* @param {Function} [deps.promptFn] - Override for the interactive
|
|
207
|
+
* access-key prompt (`promptWithBrowserOpen`). Lets tests drive
|
|
208
|
+
* the step-1 prompt and the step-2 stale-key re-prompt recovery
|
|
209
|
+
* without a real TTY/stdin. Default: the real
|
|
210
|
+
* `promptWithBrowserOpen`. Same test-seam pattern as `confirmFn`.
|
|
205
211
|
*/
|
|
206
212
|
export async function runInit(argv, io = {}, deps = {}) {
|
|
207
213
|
const stdout = io.stdout ?? process.stdout;
|
|
208
214
|
const stderr = io.stderr ?? process.stderr;
|
|
215
|
+
// Test-seam for the interactive credential prompt. Production uses the
|
|
216
|
+
// real browser-assisted prompt; tests inject a stub to exercise the
|
|
217
|
+
// step-1 and stale-key re-prompt paths deterministically.
|
|
218
|
+
const promptForKey = deps.promptFn ?? promptWithBrowserOpen;
|
|
209
219
|
|
|
210
220
|
const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
|
|
211
221
|
|
|
@@ -270,7 +280,7 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
270
280
|
hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
|
|
271
281
|
});
|
|
272
282
|
}
|
|
273
|
-
apiKey = await
|
|
283
|
+
apiKey = await promptForKey(
|
|
274
284
|
cliAuthUrl(serverUrl),
|
|
275
285
|
"Enter your access key (sk_live_...)",
|
|
276
286
|
);
|
|
@@ -343,7 +353,7 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
343
353
|
) {
|
|
344
354
|
p.warning("Existing config has an invalid key. Re-prompting for a new one.");
|
|
345
355
|
apiKey = (
|
|
346
|
-
await
|
|
356
|
+
await promptForKey(
|
|
347
357
|
cliAuthUrl(serverUrl),
|
|
348
358
|
"Enter your access key (sk_live_...)",
|
|
349
359
|
)
|
package/src/lib/http.mjs
CHANGED
|
@@ -1118,9 +1118,19 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
|
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
1120
|
const body = await parseJsonOrThrow(res, url);
|
|
1121
|
+
const skills = body.skills ?? [];
|
|
1121
1122
|
return {
|
|
1122
|
-
skills
|
|
1123
|
-
|
|
1123
|
+
skills,
|
|
1124
|
+
// When the server omits pagination, default `total` to the number of
|
|
1125
|
+
// skills actually returned — never 0 — so the displayed count can't
|
|
1126
|
+
// contradict the visible rows (a fabricated total:0 made `search`
|
|
1127
|
+
// print "0 results" beneath a result, and `--json` emit a total of 0
|
|
1128
|
+
// alongside a non-empty `results`). #1923.
|
|
1129
|
+
pagination: body.pagination ?? {
|
|
1130
|
+
total: skills.length,
|
|
1131
|
+
limit: opts.limit ?? 20,
|
|
1132
|
+
offset: opts.offset ?? 0,
|
|
1133
|
+
},
|
|
1124
1134
|
};
|
|
1125
1135
|
}
|
|
1126
1136
|
|
package/src/lib/sync.mjs
CHANGED
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
* version + content SHAs of every skill written in the last
|
|
32
32
|
* successful sync. Unblocks per-skill drift detection in `list`
|
|
33
33
|
* (#1555) and any future `status`-style command.
|
|
34
|
+
* Later (#1911) v2 also carries `cliVersion` — the version of the
|
|
35
|
+
* CLI that last wrote the state file. Additive, not a schema bump:
|
|
36
|
+
* absence is meaningful (a state file with no `cliVersion` was
|
|
37
|
+
* written by a pre-#1911 CLI and triggers the one-time membership
|
|
38
|
+
* heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
|
|
34
39
|
*
|
|
35
40
|
* Forward/backward compatibility
|
|
36
41
|
* ------------------------------
|
|
@@ -88,13 +93,20 @@
|
|
|
88
93
|
* @property {number} schemaVersion
|
|
89
94
|
* @property {string|null} etag
|
|
90
95
|
* @property {string} syncedAt
|
|
96
|
+
* @property {string} [cliVersion] - v2+ (#1911). Version of the CLI that last
|
|
97
|
+
* wrote this state. Absent on files written
|
|
98
|
+
* by a pre-#1911 CLI; absence triggers the
|
|
99
|
+
* one-time membership heal.
|
|
91
100
|
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
92
101
|
*/
|
|
93
102
|
|
|
94
103
|
import { existsSync, readFileSync } from "node:fs";
|
|
95
104
|
import { join } from "node:path";
|
|
96
105
|
|
|
106
|
+
import semver from "semver";
|
|
107
|
+
|
|
97
108
|
import { getLibrary, postSyncReceipt } from "./http.mjs";
|
|
109
|
+
import { getCliVersion } from "./cli-version.mjs";
|
|
98
110
|
import {
|
|
99
111
|
writeSkillDir,
|
|
100
112
|
removeSkillDir,
|
|
@@ -118,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
118
130
|
*/
|
|
119
131
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
120
132
|
|
|
133
|
+
/**
|
|
134
|
+
* One-time heal floor (#1911).
|
|
135
|
+
*
|
|
136
|
+
* A `.last-sync` written by a CLI BELOW this version predates the
|
|
137
|
+
* server-side delta-membership fix. Such a state file can be permanently
|
|
138
|
+
* missing skills that were added to the library but never delivered: the
|
|
139
|
+
* old delta filter keyed only on `skills.updatedAt`, so a skill whose
|
|
140
|
+
* content predated the user's `since` was skipped even though the library
|
|
141
|
+
* ETag advanced (count++). The client then cached the advanced ETag and
|
|
142
|
+
* every subsequent `update` short-circuited on a 304 — the skill could
|
|
143
|
+
* never arrive via delta.
|
|
144
|
+
*
|
|
145
|
+
* The server fix makes FUTURE adds correct, but it cannot rescue a client
|
|
146
|
+
* that already cached the advanced ETag (it still matches → 304). So the
|
|
147
|
+
* FIRST sync performed by a CLI at/above this floor forces ONE full fetch
|
|
148
|
+
* (drops `If-None-Match` AND `since`), re-fetching the entire library to
|
|
149
|
+
* fill any gap, then resumes normal delta. `writeLastSync` stamps the
|
|
150
|
+
* running CLI version on every write, so the heal fires at most once per
|
|
151
|
+
* upgrade.
|
|
152
|
+
*
|
|
153
|
+
* MUST equal the CLI version that ships this fix (see packages/cli/package.json).
|
|
154
|
+
*/
|
|
155
|
+
export const FULL_RESYNC_FLOOR = "4.8.0";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* True when a `.last-sync`'s recorded writer version is below the heal
|
|
159
|
+
* floor — i.e. the state file was written before the #1911 fix and may be
|
|
160
|
+
* missing never-delivered skills. A missing or unparseable version is
|
|
161
|
+
* treated as below the floor (older CLIs never stamped `cliVersion`), so
|
|
162
|
+
* every pre-fix state file heals exactly once.
|
|
163
|
+
*
|
|
164
|
+
* @param {unknown} recordedVersion - `.last-sync`'s `cliVersion` field.
|
|
165
|
+
* @param {string} floor - The heal floor (a valid semver string).
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function cliVersionBelowFloor(recordedVersion, floor) {
|
|
169
|
+
if (typeof recordedVersion !== "string" || !semver.valid(recordedVersion)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return semver.lt(recordedVersion, floor);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The running CLI's version, or null if it cannot be read. `getCliVersion`
|
|
177
|
+
* throws on a malformed/absent package.json (a broken tarball); persisting
|
|
178
|
+
* last-sync state must never fail on that, so we soft-handle to null here.
|
|
179
|
+
* A null stamp simply means the next run treats this state as pre-floor and
|
|
180
|
+
* heals again — safe, just one extra full fetch in the (production-
|
|
181
|
+
* impossible) broken-tarball case.
|
|
182
|
+
*
|
|
183
|
+
* @returns {string | null}
|
|
184
|
+
*/
|
|
185
|
+
function safeCliVersion() {
|
|
186
|
+
try {
|
|
187
|
+
return getCliVersion();
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
121
193
|
/**
|
|
122
194
|
* Check whether every skill in the `.last-sync` baseline already exists
|
|
123
195
|
* under every placement target the current sync would write to. Returns
|
|
@@ -297,17 +369,26 @@ export function readLastSync() {
|
|
|
297
369
|
* sync"; consumers should treat absent entries as "unknown" rather
|
|
298
370
|
* than as "missing from library."
|
|
299
371
|
*
|
|
372
|
+
* Always stamps `cliVersion` with the running CLI's version (#1911). This
|
|
373
|
+
* is the writer-version record the heal gate (`cliVersionBelowFloor`) reads
|
|
374
|
+
* on the next run; a state file written by the current CLI is therefore
|
|
375
|
+
* never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
|
|
376
|
+
* only by tests to simulate a pre-fix writer); production callers omit it.
|
|
377
|
+
*
|
|
300
378
|
* @param {object} state
|
|
301
379
|
* @param {string | null} [state.etag]
|
|
302
380
|
* @param {string} [state.syncedAt]
|
|
303
381
|
* @param {Record<string, SyncedSkillEntry>} [state.skills]
|
|
382
|
+
* @param {string | null} [state.cliVersion] - Override the writer-version
|
|
383
|
+
* stamp. Defaults to the running CLI's version.
|
|
304
384
|
*/
|
|
305
|
-
export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
385
|
+
export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
|
|
306
386
|
const path = globalLastSyncPath();
|
|
307
387
|
const body = {
|
|
308
388
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
309
389
|
etag: etag ?? null,
|
|
310
390
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
391
|
+
cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
|
|
311
392
|
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
312
393
|
};
|
|
313
394
|
try {
|
|
@@ -390,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
|
|
|
390
471
|
etag: prior?.etag ?? null,
|
|
391
472
|
syncedAt: prior?.syncedAt,
|
|
392
473
|
skills: updated,
|
|
474
|
+
// Preserve the prior writer-version too (#1911). `add`/`get` fetch a
|
|
475
|
+
// SINGLE skill — they do NOT perform the full-library reconciliation
|
|
476
|
+
// the membership heal needs, so they must NOT advance the heal gate.
|
|
477
|
+
// Letting `writeLastSync` default-stamp the running version here would
|
|
478
|
+
// let a stuck user "spend" their one-time heal on a single-skill add,
|
|
479
|
+
// leaving every OTHER never-delivered skill stuck forever. `?? null`
|
|
480
|
+
// carries a pre-fix file's absent version forward as an explicit null
|
|
481
|
+
// so the next `update` still heals. Same rationale as etag/syncedAt.
|
|
482
|
+
cliVersion: prior?.cliVersion ?? null,
|
|
393
483
|
});
|
|
394
484
|
}
|
|
395
485
|
|
|
@@ -413,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
413
503
|
etag: prior.etag ?? null,
|
|
414
504
|
syncedAt: prior.syncedAt,
|
|
415
505
|
skills: rest,
|
|
506
|
+
// Preserve the prior writer-version (#1911) — `remove` is not a full
|
|
507
|
+
// reconciliation, so it must not advance the one-time heal gate.
|
|
508
|
+
// See upsertLastSyncEntry for the full rationale.
|
|
509
|
+
cliVersion: prior.cliVersion ?? null,
|
|
416
510
|
});
|
|
417
511
|
}
|
|
418
512
|
|
|
@@ -527,19 +621,41 @@ export async function runSync(options) {
|
|
|
527
621
|
const placementsComplete =
|
|
528
622
|
lastSync?.etag &&
|
|
529
623
|
placementsAreComplete(lastSync.skills, vendors, global);
|
|
530
|
-
|
|
624
|
+
|
|
625
|
+
// One-time membership-heal (#1911). A `.last-sync` written by a CLI below
|
|
626
|
+
// the heal floor predates the server delta-membership fix and may be
|
|
627
|
+
// permanently missing skills that were added to the library but never
|
|
628
|
+
// delivered (their content predated `since`, so the old delta skipped
|
|
629
|
+
// them while the ETag still advanced — the client cached the advanced
|
|
630
|
+
// ETag and every later sync 304'd). The server fix can't rescue such a
|
|
631
|
+
// client: its cached ETag still matches, so it keeps getting 304s. Force
|
|
632
|
+
// ONE full fetch (drop BOTH conditional headers) so the entire library
|
|
633
|
+
// is re-fetched and the gap is filled. `writeLastSync` stamps the running
|
|
634
|
+
// version on the resulting write, so this fires at most once per upgrade.
|
|
635
|
+
//
|
|
636
|
+
// Mixed-version note (benign, do NOT "fix" with a different mechanism): if
|
|
637
|
+
// a pre-4.8.0 CLI shares this same global `.last-sync` and runs after a
|
|
638
|
+
// 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
|
|
639
|
+
// the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
|
|
640
|
+
// is idempotent — and self-resolves once the older install is upgraded.
|
|
641
|
+
const needsFullResync =
|
|
642
|
+
!!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
|
|
643
|
+
|
|
644
|
+
if (placementsComplete && !needsFullResync) {
|
|
531
645
|
opts.ifNoneMatch = lastSync.etag;
|
|
532
646
|
}
|
|
533
|
-
if (lastSync?.syncedAt && placementsComplete) {
|
|
647
|
+
if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
|
|
534
648
|
opts.since = lastSync.syncedAt;
|
|
535
649
|
}
|
|
536
650
|
|
|
537
651
|
// Track whether this is a full or delta sync BEFORE the network
|
|
538
|
-
// call, for the returned summary's `fullSync` field.
|
|
539
|
-
// is
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
652
|
+
// call, for the returned summary's `fullSync` field. The documented
|
|
653
|
+
// semantic is "no PRIOR last-sync state existed" — NOT "no `since` was
|
|
654
|
+
// sent". A forced full re-fetch (placement-incomplete, or the #1911
|
|
655
|
+
// membership heal) still reports `fullSync:false` because prior state
|
|
656
|
+
// existed; only a genuine first sync reports true. Consumers (init.mjs)
|
|
657
|
+
// rely on this to tell "empty library" from "nothing changed since last
|
|
658
|
+
// sync" in the zero-counters case. Locked by sync.test.mjs.
|
|
543
659
|
const fullSync = !lastSync?.syncedAt;
|
|
544
660
|
|
|
545
661
|
const result = await getLibrary(serverUrl, apiKey, opts);
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
|
|
11
11
|
import { runAdd } from "../../commands/add.mjs";
|
|
12
12
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
13
|
+
import { writeLastSync } from "../../lib/sync.mjs";
|
|
13
14
|
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
|
|
14
15
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
16
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
@@ -304,3 +305,79 @@ describe("runAdd — error paths", () => {
|
|
|
304
305
|
);
|
|
305
306
|
});
|
|
306
307
|
});
|
|
308
|
+
|
|
309
|
+
// ── #1911 bypass: add must NOT delta-filter ───────────────────────────
|
|
310
|
+
//
|
|
311
|
+
// add.mjs deliberately uses a single-skill GET (not runSync) precisely
|
|
312
|
+
// so a skill whose content predates the user's last sync still lands —
|
|
313
|
+
// the original #1911 trigger was an established user adding an OLDER
|
|
314
|
+
// catalog skill and relying on delta sync, which silently dropped it.
|
|
315
|
+
// add's whole protective property is "I never apply `since` filtering."
|
|
316
|
+
// This was untested at the command layer; lock it.
|
|
317
|
+
describe("runAdd — #1911 bypass: a skill older than last sync still lands", () => {
|
|
318
|
+
beforeEach(setup);
|
|
319
|
+
afterEach(teardown);
|
|
320
|
+
|
|
321
|
+
it("writes a skill whose updatedAt predates .last-sync.syncedAt, bypassing the delta endpoint", async () => {
|
|
322
|
+
// Seed a RECENT last sync — newer than the skill we're about to add.
|
|
323
|
+
// If `add` ever regressed to delta semantics, `updatedAt < since`
|
|
324
|
+
// would filter this skill out and it would never reach disk.
|
|
325
|
+
writeLastSync({
|
|
326
|
+
etag: '"lib-v9"',
|
|
327
|
+
syncedAt: "2099-01-01T00:00:00Z",
|
|
328
|
+
skills: {},
|
|
329
|
+
});
|
|
330
|
+
server.resetLibraryInspection();
|
|
331
|
+
|
|
332
|
+
// The catalog skill is OLD content (updatedAt years before `since`).
|
|
333
|
+
server.setSkillResponse("alice", "old-skill", {
|
|
334
|
+
...makeSkill("alice", "old-skill"),
|
|
335
|
+
updatedAt: "2020-01-01T00:00:00Z",
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/old-skill"], { stdout });
|
|
339
|
+
|
|
340
|
+
assert.ok(
|
|
341
|
+
existsSync(join(resolvePlacementDir("claudeProject", "old-skill"), "SKILL.md")),
|
|
342
|
+
"an older-than-last-sync skill MUST land on disk via `add`",
|
|
343
|
+
);
|
|
344
|
+
// The protective invariant: add never touched the delta-filtered
|
|
345
|
+
// library endpoint, so `since` could not have dropped the skill.
|
|
346
|
+
assert.equal(
|
|
347
|
+
server.getLibraryRequestCount(),
|
|
348
|
+
0,
|
|
349
|
+
"add must bypass GET /library entirely (no `since` filter can apply)",
|
|
350
|
+
);
|
|
351
|
+
assert.match(stdout.text(), /Added @alice\/old-skill/);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ── Best-effort .last-sync write (#1574 contract) ─────────────────────
|
|
356
|
+
describe("runAdd — best-effort .last-sync write failure does not fail the command", () => {
|
|
357
|
+
beforeEach(setup);
|
|
358
|
+
afterEach(teardown);
|
|
359
|
+
|
|
360
|
+
it("adds the skill and exits cleanly even when the .last-sync state write throws", async () => {
|
|
361
|
+
// Same mechanism as get: occupy the global skillrepo state-dir path
|
|
362
|
+
// with a file so the upsertLastSyncEntry → writeLastSync write throws
|
|
363
|
+
// a diskError. add's whole job (POST + fetch + write the skill to the
|
|
364
|
+
// project placement) must still succeed; the state write is
|
|
365
|
+
// best-effort and the throw must be swallowed.
|
|
366
|
+
mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
|
|
367
|
+
writeFileSync(join(process.env.HOME, ".claude", "skillrepo"), "not-a-dir");
|
|
368
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
369
|
+
|
|
370
|
+
await assert.doesNotReject(
|
|
371
|
+
() =>
|
|
372
|
+
runAdd(
|
|
373
|
+
["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper", "--agent", "claude"],
|
|
374
|
+
{ stdout },
|
|
375
|
+
),
|
|
376
|
+
"a .last-sync write failure must NOT fail `add` after the skill files landed",
|
|
377
|
+
);
|
|
378
|
+
assert.ok(
|
|
379
|
+
existsSync(join(resolvePlacementDir("claudeProject", "pdf-helper"), "SKILL.md")),
|
|
380
|
+
"the skill files must still be on disk despite the state-write failure",
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
|
|
11
11
|
import { runGet } from "../../commands/get.mjs";
|
|
12
12
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
CliError,
|
|
15
|
+
EXIT_VALIDATION,
|
|
16
|
+
EXIT_AUTH,
|
|
17
|
+
EXIT_SCOPE,
|
|
18
|
+
EXIT_NETWORK,
|
|
19
|
+
} from "../../lib/errors.mjs";
|
|
14
20
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
21
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
22
|
import {
|
|
@@ -180,3 +186,126 @@ describe("runGet — error paths", () => {
|
|
|
180
186
|
);
|
|
181
187
|
});
|
|
182
188
|
});
|
|
189
|
+
|
|
190
|
+
// ── Coverage gap closure: --agent vectors + transport errors ───────────
|
|
191
|
+
//
|
|
192
|
+
// These mirror the equivalent vectors already locked in add.test.mjs
|
|
193
|
+
// (`--agent none` rejection, `--agent cursor` placement, 401/403/network
|
|
194
|
+
// transport mapping). `get` and `add` share `requireVendorTargets` and
|
|
195
|
+
// the `http.mjs` error mapping, so the assertions here assert the SAME
|
|
196
|
+
// advertised contract: exit 5 for the no-op flag, exit 2/4 for auth/scope,
|
|
197
|
+
// exit 1 for a network failure, and the cohort `.agents/skills/` placement
|
|
198
|
+
// for `--agent cursor`.
|
|
199
|
+
describe("runGet — --agent flag + transport error paths", () => {
|
|
200
|
+
beforeEach(setup);
|
|
201
|
+
afterEach(teardown);
|
|
202
|
+
|
|
203
|
+
it("rejects --agent none with a validation error (no targets to write)", async () => {
|
|
204
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
205
|
+
await assert.rejects(
|
|
206
|
+
() =>
|
|
207
|
+
runGet(
|
|
208
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "none", "@alice/pdf-helper"],
|
|
209
|
+
{ stdout },
|
|
210
|
+
),
|
|
211
|
+
(err) =>
|
|
212
|
+
err instanceof CliError &&
|
|
213
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
214
|
+
/--agent none has no effect on `skillrepo get`/.test(err.message),
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("--agent cursor writes to the cohort .agents/skills/ placement, NOT the claude placement", async () => {
|
|
219
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
220
|
+
await runGet(
|
|
221
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
|
|
222
|
+
{ stdout },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Landed in cursor's project target (.agents/skills/).
|
|
226
|
+
const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
|
|
227
|
+
assert.ok(
|
|
228
|
+
existsSync(join(agentsDir, "SKILL.md")),
|
|
229
|
+
"--agent cursor must write the skill into .agents/skills/",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Did NOT also write the Claude Code placement — the flag is scoped.
|
|
233
|
+
// This couples the assertion to the placement behavior: a regression
|
|
234
|
+
// that ignored --agent and defaulted to claudeProject would fail here.
|
|
235
|
+
const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
236
|
+
assert.ok(
|
|
237
|
+
!existsSync(claudeDir),
|
|
238
|
+
"--agent cursor must NOT write the claude placement",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("401 auth error exits 2", async () => {
|
|
243
|
+
// The single-skill GET route has no per-skill error slot, so force
|
|
244
|
+
// the next request (getSkill) to return 401 — the same outcome
|
|
245
|
+
// add.test.mjs reaches via setAddResponse(status: 401).
|
|
246
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
247
|
+
await assert.rejects(
|
|
248
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/auth-test"], { stdout }),
|
|
249
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("403 scope-required maps to scopeError (exit 4)", async () => {
|
|
254
|
+
// Mirror add.test.mjs's 403-scope response shape (code: scope_required).
|
|
255
|
+
server.setForcedStatus(403, { error: "Insufficient scope", code: "scope_required" });
|
|
256
|
+
await assert.rejects(
|
|
257
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/scope-test"], { stdout }),
|
|
258
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("network failure exits 1 (network)", async () => {
|
|
263
|
+
// Point at a port nothing is listening on — fetch rejects with a
|
|
264
|
+
// connection error that safeFetch wraps as networkError (exit 1).
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
() =>
|
|
267
|
+
runGet(
|
|
268
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "@alice/pdf-helper"],
|
|
269
|
+
{ stdout },
|
|
270
|
+
),
|
|
271
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── Best-effort .last-sync write (#1574 contract) ─────────────────────
|
|
277
|
+
//
|
|
278
|
+
// get writes the skill bytes to disk, THEN records the skill in
|
|
279
|
+
// .last-sync so `list` can see it. That second write is best-effort: if
|
|
280
|
+
// it fails, the user already has the skill on disk, so the command must
|
|
281
|
+
// NOT turn a successful fetch into a failure. get.mjs wraps it in a
|
|
282
|
+
// try/catch; lock that the swallow actually happens.
|
|
283
|
+
describe("runGet — best-effort .last-sync write failure does not fail the command", () => {
|
|
284
|
+
beforeEach(setup);
|
|
285
|
+
afterEach(teardown);
|
|
286
|
+
|
|
287
|
+
it("writes the skill and exits cleanly even when the .last-sync state write throws", async () => {
|
|
288
|
+
// Occupy the global skillrepo state-dir PATH with a regular file, so
|
|
289
|
+
// writeLastSync (via upsertLastSyncEntry) can't create the directory
|
|
290
|
+
// and throws a diskError. Cross-platform: a file where a directory is
|
|
291
|
+
// expected fails identically on POSIX and Windows (no chmod needed).
|
|
292
|
+
// The skill itself lands in the PROJECT placement (a different path),
|
|
293
|
+
// so the command's real work succeeds.
|
|
294
|
+
mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
|
|
295
|
+
writeFileSync(join(process.env.HOME, ".claude", "skillrepo"), "not-a-dir");
|
|
296
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
297
|
+
|
|
298
|
+
await assert.doesNotReject(
|
|
299
|
+
() =>
|
|
300
|
+
runGet(
|
|
301
|
+
["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper", "--agent", "claude"],
|
|
302
|
+
{ stdout },
|
|
303
|
+
),
|
|
304
|
+
"a .last-sync write failure must NOT fail `get` after the skill files landed",
|
|
305
|
+
);
|
|
306
|
+
assert.ok(
|
|
307
|
+
existsSync(join(resolvePlacementDir("claudeProject", "pdf-helper"), "SKILL.md")),
|
|
308
|
+
"the skill files must still be on disk despite the state-write failure",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|