skillrepo 4.8.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/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/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 +139 -5
- 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
|
|
|
@@ -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
|
+
});
|