skillrepo 4.8.0 → 4.8.2
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/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- 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 +136 -3
- 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/cli-commands.test.mjs +1 -1
- 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 +616 -1
- 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/add.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* - 404 not-found → clean error, exit 5
|
|
28
28
|
* - 409 self-ownership → clean error, exit 5 ("you can't add your
|
|
29
29
|
* own skill to your own library")
|
|
30
|
-
* - 403 plan-limit → validationError (via http.mjs)
|
|
30
|
+
* - 403 plan-limit → validationError (via http.mjs); the server
|
|
31
|
+
* message carries the per-cap remedy (no CLI-side hint, #2239)
|
|
31
32
|
* - 403 scope → scopeError (via http.mjs) with write-key hint
|
|
32
33
|
*
|
|
33
34
|
* Flags: --global / --agent / --json / --key / --url
|
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
|
@@ -339,7 +339,7 @@ async function mapErrorResponse(res, url) {
|
|
|
339
339
|
// `src/app/api/v1/library/route.ts:226-235`. The route comment
|
|
340
340
|
// EXPLICITLY documents that the CLI must distinguish this from
|
|
341
341
|
// scope errors. We map it to `validationError` (exit 5) with a
|
|
342
|
-
//
|
|
342
|
+
// no CLI-side hint — it is not a key-permission issue
|
|
343
343
|
// so neither authError nor scopeError is the right fit, and
|
|
344
344
|
// adding a dedicated planLimitError to the exit code matrix is
|
|
345
345
|
// out of scope for v3.0.0.
|
|
@@ -350,9 +350,13 @@ async function mapErrorResponse(res, url) {
|
|
|
350
350
|
// 3. Generic 403 — suspended account, blocked user, etc. Maps to
|
|
351
351
|
// authError with a "contact support" hint.
|
|
352
352
|
if (typeof code === "string" && code === "plan_limit") {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
// No CLI-side hint: the server's plan_limit message now states the
|
|
354
|
+
// limit AND the correct remedy for the specific cap that fired
|
|
355
|
+
// (skills "Delete one to add another.", library "Remove one to add
|
|
356
|
+
// another.", cap-0 "A personal library isn't included in your
|
|
357
|
+
// plan."). A generic hint here either duplicates it or — for the
|
|
358
|
+
// cap-0 tier — contradicts it (#2239 round-2 review).
|
|
359
|
+
return validationError(message);
|
|
356
360
|
}
|
|
357
361
|
if (typeof code === "string" && code.toLowerCase().includes("scope")) {
|
|
358
362
|
return scopeError(message, {
|
|
@@ -1118,9 +1122,19 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
|
|
|
1118
1122
|
}
|
|
1119
1123
|
|
|
1120
1124
|
const body = await parseJsonOrThrow(res, url);
|
|
1125
|
+
const skills = body.skills ?? [];
|
|
1121
1126
|
return {
|
|
1122
|
-
skills
|
|
1123
|
-
|
|
1127
|
+
skills,
|
|
1128
|
+
// When the server omits pagination, default `total` to the number of
|
|
1129
|
+
// skills actually returned — never 0 — so the displayed count can't
|
|
1130
|
+
// contradict the visible rows (a fabricated total:0 made `search`
|
|
1131
|
+
// print "0 results" beneath a result, and `--json` emit a total of 0
|
|
1132
|
+
// alongside a non-empty `results`). #1923.
|
|
1133
|
+
pagination: body.pagination ?? {
|
|
1134
|
+
total: skills.length,
|
|
1135
|
+
limit: opts.limit ?? 20,
|
|
1136
|
+
offset: opts.offset ?? 0,
|
|
1137
|
+
},
|
|
1124
1138
|
};
|
|
1125
1139
|
}
|
|
1126
1140
|
|
|
@@ -1223,7 +1237,7 @@ async function setSkillVisibility(serverUrl, apiKey, owner, name, target, option
|
|
|
1223
1237
|
// CANNOT fall through to `mapErrorResponse` (which re-reads the
|
|
1224
1238
|
// body and would get an empty object — a `plan_limit` 403 would
|
|
1225
1239
|
// mis-classify as authError exit 2 instead of validationError
|
|
1226
|
-
// exit 5
|
|
1240
|
+
// exit 5, message self-contained). Pre-parse once, then dispatch every
|
|
1227
1241
|
// code path inline mirroring `mapErrorResponse`'s shape so the
|
|
1228
1242
|
// CliError types stay consistent across endpoints.
|
|
1229
1243
|
if (res.status === 403 || res.status === 422) {
|
|
@@ -1243,13 +1257,13 @@ async function setSkillVisibility(serverUrl, apiKey, owner, name, target, option
|
|
|
1243
1257
|
}
|
|
1244
1258
|
// Mirror `mapErrorResponse`'s 403 taxonomy so a 403 here behaves
|
|
1245
1259
|
// identically to a 403 on any other endpoint:
|
|
1246
|
-
// - `plan_limit` → validationError (exit 5)
|
|
1260
|
+
// - `plan_limit` → validationError (exit 5), server message only
|
|
1247
1261
|
// - any `*scope*` code → scopeError (exit 4)
|
|
1248
1262
|
// - everything else → authError (exit 2, "suspended" hint)
|
|
1249
1263
|
if (code === "plan_limit") {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1264
|
+
// Same as mapErrorResponse: the server message is the complete,
|
|
1265
|
+
// cap-specific remedy — no CLI hint (#2239 round-2 review).
|
|
1266
|
+
throw validationError(message);
|
|
1253
1267
|
}
|
|
1254
1268
|
if (code && code.toLowerCase().includes("scope")) {
|
|
1255
1269
|
throw scopeError(message, {
|
|
@@ -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";
|
|
@@ -234,7 +235,7 @@ describe("runAdd — error paths", () => {
|
|
|
234
235
|
);
|
|
235
236
|
});
|
|
236
237
|
|
|
237
|
-
it("403 plan-limit maps to validationError
|
|
238
|
+
it("403 plan-limit maps to validationError (server message self-contained, no hint)", async () => {
|
|
238
239
|
server.setAddResponse("alice", "limit-test", {
|
|
239
240
|
status: 403,
|
|
240
241
|
body: {
|
|
@@ -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
|
+
});
|