skillrepo 2.0.0 → 3.0.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.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -1,56 +1,23 @@
1
1
  /**
2
2
  * Interactive prompts using Node's built-in readline.
3
3
  * Zero dependencies. Supports TTY detection and NO_COLOR.
4
+ *
5
+ * v3.0.0 cleanup (PR4 cross-review): the old v2.0.0 print helpers
6
+ * (printHeader, printStep, printSuccess, printWarning, printError,
7
+ * printResult, printBlank) that wrote to `console.log` were removed.
8
+ * They wrote directly to `process.stdout` via `console.log`, bypassing
9
+ * the stream-injection pattern every v3.0.0 command uses for
10
+ * testability. `init.mjs` defines its own `makePrinter` helper that
11
+ * ties into the injected io.stdout/io.stderr streams; every other
12
+ * command uses the same pattern. This module now only exports the
13
+ * three interactive primitives (`promptText`, `promptSecret`,
14
+ * `confirm`) that still need direct stdin/stdout access.
4
15
  */
5
16
 
6
17
  import { createInterface } from "node:readline";
7
18
 
8
19
  const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
9
-
10
- // ── Colors ──────────────────────────────────────────────────────────────
11
-
12
- const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
13
- const yellow = (s) => (isTTY ? `\x1b[33m${s}\x1b[0m` : s);
14
- const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
15
20
  const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
16
- const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
17
-
18
- // ── Output helpers ──────────────────────────────────────────────────────
19
-
20
- export function printHeader(title) {
21
- console.log("");
22
- console.log(` ${bold(title)}`);
23
- console.log("");
24
- }
25
-
26
- export function printStep(n, total, message) {
27
- console.log(` ${dim(`Step ${n}/${total}:`)} ${message}`);
28
- }
29
-
30
- export function printSuccess(message) {
31
- console.log(` ${green("✓")} ${message}`);
32
- }
33
-
34
- export function printWarning(message) {
35
- console.log(` ${yellow("⚠")} ${message}`);
36
- }
37
-
38
- export function printError(message) {
39
- console.error(` ${red("✗")} ${message}`);
40
- }
41
-
42
- export function printResult(path, action) {
43
- const label =
44
- action === "created" ? green("created") :
45
- action === "merged" ? yellow("merged") :
46
- action === "updated" ? yellow("updated") :
47
- dim("skipped");
48
- console.log(` ${path.padEnd(45)} ${label}`);
49
- }
50
-
51
- export function printBlank() {
52
- console.log("");
53
- }
54
21
 
55
22
  // ── Prompts ─────────────────────────────────────────────────────────────
56
23
 
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Shared library sync engine (#675 partial — used by update/get/init).
3
+ *
4
+ * Wraps the http.mjs `getLibrary` call with:
5
+ * • ETag-based conditional requests (skip work if nothing changed)
6
+ * • Persistent last-sync state at ~/.claude/skillrepo/.last-sync
7
+ * • Tombstone application via `removeSkillDir`
8
+ * • Per-skill writes via `writeSkillDir`
9
+ * • A summary suitable for both human and --json output
10
+ *
11
+ * Architectural notes:
12
+ *
13
+ * • `update.mjs` is a thin wrapper around `runSync` plus a printer.
14
+ * • PR3a's `add` does NOT use this — it does a single-skill GET +
15
+ * direct write (avoids clock-skew on the `since` filter).
16
+ * • PR3a's `remove` does NOT use this — it deletes locally directly
17
+ * (avoids the ETag cache poisoning on tombstones, see #875).
18
+ * • PR3b's `init` calls `runSync({ vendors, global })` after writing
19
+ * the global config to perform the first pull.
20
+ *
21
+ * The state file format is JSON with an explicit `schemaVersion` so we
22
+ * can evolve it without breaking older CLIs catastrophically. Future
23
+ * versions should bump the schema when fields are added/removed.
24
+ *
25
+ * @typedef {Object} SyncSummary
26
+ * @property {number} added - Skills newly written that were not on disk
27
+ * @property {number} updated - Skills overwritten on disk
28
+ * @property {number} removed - Tombstones applied
29
+ * @property {boolean} notModified - True if 304 short-circuit fired
30
+ * @property {string} syncedAt - ISO timestamp from the server response
31
+ *
32
+ * @typedef {Object} SyncStateFile
33
+ * @property {number} schemaVersion
34
+ * @property {string|null} etag
35
+ * @property {string} syncedAt
36
+ */
37
+
38
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
39
+ import { dirname } from "node:path";
40
+
41
+ import { getLibrary } from "./http.mjs";
42
+ import { writeSkillDir, removeSkillDir, cleanupOrphans } from "./file-write.mjs";
43
+ import {
44
+ globalLastSyncPath,
45
+ claudeSkillsProject,
46
+ claudeSkillsGlobal,
47
+ projectSkillsFallback,
48
+ } from "./paths.mjs";
49
+ import { diskError, validationError } from "./errors.mjs";
50
+
51
+ /**
52
+ * Current schema version of the .last-sync file. Bump on any
53
+ * structural change. The reader treats unknown future versions as
54
+ * "ignore the cache and do a full sync" — forward-compat without
55
+ * crashing.
56
+ */
57
+ export const LAST_SYNC_SCHEMA_VERSION = 1;
58
+
59
+ /**
60
+ * Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
61
+ * Returns null if the file doesn't exist, is malformed, or has a
62
+ * schema version we don't recognize.
63
+ *
64
+ * Intentionally lossy on parse failure — we can always do a full
65
+ * sync. We never crash the CLI on a corrupt cache file.
66
+ */
67
+ export function readLastSync() {
68
+ const path = globalLastSyncPath();
69
+ if (!existsSync(path)) return null;
70
+
71
+ let raw;
72
+ try {
73
+ raw = readFileSync(path, "utf-8");
74
+ } catch {
75
+ return null;
76
+ }
77
+
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch {
82
+ return null;
83
+ }
84
+
85
+ if (
86
+ !parsed ||
87
+ typeof parsed !== "object" ||
88
+ parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION
89
+ ) {
90
+ return null;
91
+ }
92
+
93
+ return parsed;
94
+ }
95
+
96
+ /**
97
+ * Persist the last-sync state. Throws `diskError` on failure — the
98
+ * caller can decide whether to surface this as a warning or hard
99
+ * error. Most callers should treat a state-file write failure as
100
+ * non-fatal because the actual skill files were already written
101
+ * successfully.
102
+ */
103
+ export function writeLastSync({ etag, syncedAt }) {
104
+ const path = globalLastSyncPath();
105
+ const dir = dirname(path);
106
+ try {
107
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
108
+ } catch (err) {
109
+ throw diskError(`Cannot create ${dir}: ${err.message}`, { cause: err });
110
+ }
111
+ const body = {
112
+ schemaVersion: LAST_SYNC_SCHEMA_VERSION,
113
+ etag: etag ?? null,
114
+ syncedAt: syncedAt ?? new Date().toISOString(),
115
+ };
116
+ try {
117
+ writeFileSync(path, JSON.stringify(body, null, 2) + "\n", "utf-8");
118
+ } catch (err) {
119
+ throw diskError(`Cannot write ${path}: ${err.message}`, { cause: err });
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Run a library sync against the server.
125
+ *
126
+ * Strategy:
127
+ * 1. Clean up any orphan .tmp/.old directories from a crashed prior run
128
+ * 2. Read the last-sync state file
129
+ * 3. Call GET /api/v1/library with `If-None-Match` (if we have a
130
+ * cached ETag) AND `since` (always, for delta semantics)
131
+ * 4. Short-circuit on 304 — return `notModified: true`
132
+ * 5. For each skill in the response:
133
+ * a. Write it via `writeSkillDir` — overwrites if changed
134
+ * b. Skip writing skills with `filesIncomplete: true` (see comment)
135
+ * 6. For each tombstone in the response:
136
+ * a. Call `removeSkillDir` — deletes from all configured targets
137
+ * 7. Persist the new ETag (if the response was complete) for the
138
+ * next sync
139
+ * 8. Return the summary
140
+ *
141
+ * Error handling: any thrown error from the network or filesystem
142
+ * layer propagates unchanged. The caller (a command) decides how to
143
+ * present it. The state file is NOT updated on partial failure — a
144
+ * subsequent sync will retry with the previous ETag.
145
+ *
146
+ * @param {object} options
147
+ * @param {string} options.serverUrl
148
+ * @param {string} options.apiKey
149
+ * @param {string[]} [options.vendors] - Vendor keys; required unless `global`
150
+ * @param {boolean} [options.global]
151
+ * @param {object} [options.io] - Optional injected output streams. Defaults
152
+ * to process.stdout/stderr. The non-fatal warning emitted
153
+ * when `writeLastSync` fails uses io.stderr so tests that
154
+ * inject a capture stream don't lose that output.
155
+ * @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
156
+ * @returns {Promise<SyncSummary>}
157
+ */
158
+ export async function runSync(options) {
159
+ const { serverUrl, apiKey, vendors, global, io } = options;
160
+ // Coalesce both `undefined` AND `null` to {}. Destructuring with
161
+ // `io = {}` only handles `undefined`, so an explicit `io: null`
162
+ // would otherwise blow up at the .stderr access below. Both
163
+ // round-2 reviewers caught this.
164
+ const resolvedIo = io ?? {};
165
+ const stderr = resolvedIo.stderr ?? process.stderr;
166
+
167
+ if (typeof serverUrl !== "string" || serverUrl.trim() === "") {
168
+ throw validationError("runSync: serverUrl is required");
169
+ }
170
+ if (typeof apiKey !== "string" || apiKey.trim() === "") {
171
+ throw validationError("runSync: apiKey is required");
172
+ }
173
+
174
+ // Step 1: clean orphans from prior crashes BEFORE doing any new writes
175
+ cleanupOrphans({ vendors, global });
176
+
177
+ // Step 2: read prior state
178
+ const lastSync = readLastSync();
179
+
180
+ // Step 3: fetch with conditional headers
181
+ const opts = {};
182
+ if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
183
+ if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
184
+
185
+ const result = await getLibrary(serverUrl, apiKey, opts);
186
+
187
+ // Step 4: 304 short-circuit
188
+ if (result.notModified) {
189
+ return {
190
+ added: 0,
191
+ updated: 0,
192
+ removed: 0,
193
+ notModified: true,
194
+ syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
195
+ };
196
+ }
197
+
198
+ // Step 5 + 6: apply skills + removals
199
+ const summary = {
200
+ added: 0,
201
+ updated: 0,
202
+ removed: 0,
203
+ notModified: false,
204
+ syncedAt: result.syncedAt,
205
+ };
206
+
207
+ // Apply removals FIRST so a re-add (skill removed then re-added with
208
+ // the same name) can't accidentally delete a freshly-written skill.
209
+ // The server's `removals` array is only populated on delta sync (when
210
+ // `since` was set), so on a full first-sync this loop is empty.
211
+ //
212
+ // We pass `tombstone.name` only (NOT `tombstone.owner`). The CLI
213
+ // places skills by name alone — `.claude/skills/<name>/` — not by
214
+ // `.claude/skills/<owner>/<name>/`. The server prevents two skills
215
+ // from different owners sharing the same `name` in one library, so
216
+ // this is safe today. If a future PR adds owner-namespaced
217
+ // directories, this loop must be updated to disambiguate.
218
+ // Note on error handling: any throw from `removeSkillDir` propagates
219
+ // out of this loop (no try/catch wrapper). That's intentional —
220
+ // partial tombstone application is dangerous because the user
221
+ // would have local files for skills that no longer belong in their
222
+ // library. Surface the first failure loudly so the user can fix
223
+ // the cause and re-run sync.
224
+ for (const tombstone of result.removals) {
225
+ const removeResult = removeSkillDir(tombstone.name, { vendors, global });
226
+ if (removeResult.removed.length > 0) {
227
+ summary.removed++;
228
+ }
229
+ // notFound: silently ignored. A tombstone for a skill that
230
+ // never existed on this machine (added on machine A, removed on
231
+ // machine A, this is machine B) is not an error.
232
+ }
233
+
234
+ // Apply skills. We don't have on-disk SHAs to compute "unchanged"
235
+ // efficiently — `unchanged` stays 0 unless a future enhancement
236
+ // adds disk-side hashing. For the user-facing summary, "added" vs
237
+ // "updated" is distinguished by whether the target directory
238
+ // already existed before the write.
239
+ //
240
+ // Skills with `filesIncomplete: true` are SKIPPED and the ETag
241
+ // is NOT persisted — the server returned a partial payload because
242
+ // one or more files failed to inline from blob storage. Writing
243
+ // a partial skill would leave the user with broken resource
244
+ // references (the SKILL.md body would point to files that don't
245
+ // exist on disk).
246
+ let anyIncomplete = false;
247
+ for (const skill of result.skills) {
248
+ if (skill.filesIncomplete) {
249
+ anyIncomplete = true;
250
+ continue;
251
+ }
252
+ const wasAlreadyOnDisk = isAnyTargetPresent(skill.name, { vendors, global });
253
+ writeSkillDir(skill, { vendors, global });
254
+ if (wasAlreadyOnDisk) {
255
+ summary.updated++;
256
+ } else {
257
+ summary.added++;
258
+ }
259
+ }
260
+
261
+ // Step 7: persist new ETag (only if the response was complete)
262
+ if (!anyIncomplete && result.etag) {
263
+ try {
264
+ writeLastSync({ etag: result.etag, syncedAt: result.syncedAt });
265
+ } catch (err) {
266
+ // Non-fatal — the skills are on disk, we just won't get the
267
+ // 304 short-circuit on the next run. Surface a warning via
268
+ // the injected stderr stream so tests can capture it.
269
+ stderr.write(
270
+ ` warning: failed to persist last-sync state (${err.message}). ` +
271
+ `Next sync will be a full fetch.\n`,
272
+ );
273
+ }
274
+ }
275
+
276
+ return summary;
277
+ }
278
+
279
+ // ── Internals ──────────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Check whether ANY of the configured placement targets already
283
+ * contains a directory for the given skill name. Used to distinguish
284
+ * "added" from "updated" in the summary. Cheap — just an `existsSync`
285
+ * per target.
286
+ */
287
+ function isAnyTargetPresent(skillName, options) {
288
+ if (options.global) {
289
+ return existsSync(claudeSkillsGlobal(skillName));
290
+ }
291
+ if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
292
+ return false;
293
+ }
294
+ if (options.vendors.includes("claudeCode")) {
295
+ if (existsSync(claudeSkillsProject(skillName))) return true;
296
+ }
297
+ if (
298
+ options.vendors.includes("cursor") ||
299
+ options.vendors.includes("windsurf") ||
300
+ options.vendors.includes("vscode")
301
+ ) {
302
+ if (existsSync(projectSkillsFallback(skillName))) return true;
303
+ }
304
+ return false;
305
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Unit/integration tests for src/commands/add.mjs (PR3a of #646).
3
+ */
4
+
5
+ import { describe, it, beforeEach, afterEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+
11
+ import { runAdd } from "../../commands/add.mjs";
12
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
+ import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
+ import { createMockServer } from "../e2e/mock-server.mjs";
15
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+
17
+ let sandbox;
18
+ let server;
19
+ let serverUrl;
20
+ let originalCwd;
21
+ let originalHome;
22
+ let stdout;
23
+ const VALID_KEY = "sk_live_test";
24
+
25
+ function makeSkill(owner, name) {
26
+ return {
27
+ owner,
28
+ name,
29
+ version: "1.0.0",
30
+ description: `${name} description`,
31
+ files: [
32
+ {
33
+ path: "SKILL.md",
34
+ content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
35
+ sha256: "x",
36
+ size: 50,
37
+ contentType: "text/markdown",
38
+ },
39
+ ],
40
+ updatedAt: new Date().toISOString(),
41
+ };
42
+ }
43
+
44
+ async function setup() {
45
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-add-"));
46
+ mkdirSync(join(sandbox, "project"), { recursive: true });
47
+ mkdirSync(join(sandbox, "home"), { recursive: true });
48
+ originalCwd = process.cwd();
49
+ originalHome = process.env.HOME;
50
+ process.chdir(join(sandbox, "project"));
51
+ process.env.HOME = join(sandbox, "home");
52
+ delete process.env.SKILLREPO_ACCESS_KEY;
53
+
54
+ server = createMockServer({});
55
+ const port = await server.start();
56
+ serverUrl = `http://127.0.0.1:${port}`;
57
+
58
+ stdout = createCaptureStream();
59
+ }
60
+
61
+ async function teardown() {
62
+ if (server) await server.stop();
63
+ process.chdir(originalCwd);
64
+ process.env.HOME = originalHome;
65
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
+ server = null;
67
+ }
68
+
69
+ describe("runAdd — happy path", () => {
70
+ beforeEach(setup);
71
+ afterEach(teardown);
72
+
73
+ it("adds a new skill to the library and writes files locally", async () => {
74
+ // Default server response is 201 added; register the skill for the
75
+ // follow-up GET /api/v1/skills/{owner}/{name}
76
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
77
+
78
+ await runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
79
+
80
+ // POST body captured by mock server
81
+ const postBody = server.getLastPostBody();
82
+ assert.deepEqual(postBody, { owner: "alice", name: "pdf-helper" });
83
+
84
+ // Local files written
85
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
86
+ assert.ok(existsSync(join(dir, "SKILL.md")));
87
+
88
+ // Human summary mentions "Added"
89
+ assert.match(stdout.text(), /Added @alice\/pdf-helper/);
90
+ });
91
+
92
+ it("idempotent: already-in-library re-fetches and writes (refresh semantics)", async () => {
93
+ // Configure the server to respond with 409 already_in_library
94
+ server.setAddResponse("alice", "pdf-helper", {
95
+ status: 409,
96
+ body: { error: "Skill is already in your library", code: "already_in_library" },
97
+ });
98
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
99
+
100
+ await runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
101
+
102
+ // Local files still written (refresh semantics)
103
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
104
+ assert.ok(existsSync(join(dir, "SKILL.md")));
105
+
106
+ // Summary mentions the refresh path
107
+ assert.match(stdout.text(), /already in your library — refreshed/);
108
+ });
109
+
110
+ it("--json outputs added status on fresh add", async () => {
111
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
112
+ await runAdd(
113
+ ["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/pdf-helper"],
114
+ { stdout },
115
+ );
116
+ const json = JSON.parse(stdout.text());
117
+ assert.equal(json.action, "added");
118
+ assert.equal(json.owner, "alice");
119
+ assert.equal(json.name, "pdf-helper");
120
+ assert.equal(json.filesWritten, 1);
121
+ });
122
+
123
+ it("--json outputs already-in-library status on refresh", async () => {
124
+ server.setAddResponse("alice", "pdf-helper", {
125
+ status: 409,
126
+ body: { code: "already_in_library" },
127
+ });
128
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
129
+
130
+ await runAdd(
131
+ ["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/pdf-helper"],
132
+ { stdout },
133
+ );
134
+ const json = JSON.parse(stdout.text());
135
+ assert.equal(json.action, "already-in-library");
136
+ });
137
+
138
+ it("--global writes to home dir", async () => {
139
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
140
+ await runAdd(
141
+ ["--key", VALID_KEY, "--url", serverUrl, "--global", "@alice/pdf-helper"],
142
+ { stdout },
143
+ );
144
+ const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
145
+ assert.ok(existsSync(dir));
146
+ assert.ok(dir.startsWith(process.env.HOME));
147
+ });
148
+
149
+ it("--ide cursor writes to the project /skills/ fallback", async () => {
150
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
151
+ await runAdd(
152
+ ["--key", VALID_KEY, "--url", serverUrl, "--ide", "cursor", "@alice/pdf-helper"],
153
+ { stdout },
154
+ );
155
+ const dir = resolvePlacementDir("projectFallback", "pdf-helper");
156
+ assert.ok(existsSync(dir));
157
+ });
158
+ });
159
+
160
+ describe("runAdd — error paths", () => {
161
+ beforeEach(setup);
162
+ afterEach(teardown);
163
+
164
+ it("rejects missing identifier", async () => {
165
+ await assert.rejects(
166
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
167
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
168
+ );
169
+ });
170
+
171
+ it("rejects malformed identifier", async () => {
172
+ await assert.rejects(
173
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "not-an-identifier"], { stdout }),
174
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
175
+ );
176
+ });
177
+
178
+ it("rejects extra positional after identifier", async () => {
179
+ await assert.rejects(
180
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@a/b", "@c/d"], { stdout }),
181
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
182
+ );
183
+ });
184
+
185
+ it("404 not-found returns clean validation error", async () => {
186
+ server.setAddResponse("alice", "missing", {
187
+ status: 404,
188
+ body: { error: "Skill not found", code: "not_found" },
189
+ });
190
+ await assert.rejects(
191
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/missing"], { stdout }),
192
+ (err) =>
193
+ err instanceof CliError &&
194
+ err.exitCode === EXIT_VALIDATION &&
195
+ /not found/i.test(err.message),
196
+ );
197
+ });
198
+
199
+ it("409 self-ownership rejects with clean error", async () => {
200
+ server.setAddResponse("alice", "my-skill", {
201
+ status: 409,
202
+ body: {
203
+ error: "Cannot add your own skill to your library",
204
+ code: "self_ownership",
205
+ },
206
+ });
207
+ await assert.rejects(
208
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/my-skill"], { stdout }),
209
+ (err) =>
210
+ err instanceof CliError &&
211
+ err.exitCode === EXIT_VALIDATION &&
212
+ /your own skill/i.test(err.message),
213
+ );
214
+ });
215
+
216
+ it("403 plan-limit maps to validationError with billing hint", async () => {
217
+ server.setAddResponse("alice", "limit-test", {
218
+ status: 403,
219
+ body: {
220
+ error: "Your free plan allows up to 5 library skills.",
221
+ code: "plan_limit",
222
+ },
223
+ });
224
+ await assert.rejects(
225
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/limit-test"], { stdout }),
226
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /plan/i.test(err.message),
227
+ );
228
+ });
229
+
230
+ it("403 scope-required maps to scopeError (exit 4)", async () => {
231
+ server.setAddResponse("alice", "scope-test", {
232
+ status: 403,
233
+ body: { error: "Insufficient scope", code: "scope_required" },
234
+ });
235
+ await assert.rejects(
236
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/scope-test"], { stdout }),
237
+ (err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
238
+ );
239
+ });
240
+
241
+ it("401 auth error exits 2", async () => {
242
+ server.setAddResponse("alice", "auth-test", {
243
+ status: 401,
244
+ body: { error: "Invalid access key" },
245
+ });
246
+ await assert.rejects(
247
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/auth-test"], { stdout }),
248
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
249
+ );
250
+ });
251
+
252
+ it("server-returned wrong owner/name is rejected (defense in depth)", async () => {
253
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("bob", "wrong"));
254
+ // POST succeeds with default 201, but the follow-up GET returns a
255
+ // mismatched skill.
256
+ await assert.rejects(
257
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout }),
258
+ (err) => err instanceof CliError && /wrong skill/i.test(err.message),
259
+ );
260
+ });
261
+
262
+ it("filesIncomplete skill is rejected", async () => {
263
+ const incomplete = makeSkill("alice", "incomplete");
264
+ incomplete.filesIncomplete = true;
265
+ server.setSkillResponse("alice", "incomplete", incomplete);
266
+ await assert.rejects(
267
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/incomplete"], { stdout }),
268
+ (err) => err instanceof CliError && /incomplete/i.test(err.message),
269
+ );
270
+ });
271
+
272
+ it("POST succeeds but follow-up GET returns 404 surfaces as validation error", async () => {
273
+ // Server-side inconsistency: add succeeded (default 201) but the
274
+ // GET returns 404. The add command surfaces this as a clear error
275
+ // pointing the user at `update` rather than silently writing nothing.
276
+ // NOTE: no skillResponse registered for "alice/ghost" → default 404
277
+ await assert.rejects(
278
+ () => runAdd(["--key", VALID_KEY, "--url", serverUrl, "@alice/ghost"], { stdout }),
279
+ (err) =>
280
+ err instanceof CliError &&
281
+ err.exitCode === EXIT_VALIDATION &&
282
+ /added to your library but the fetch returned 404/i.test(err.message),
283
+ );
284
+ });
285
+ });