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.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
package/src/lib/prompt.mjs
CHANGED
|
@@ -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
|
|
package/src/lib/sync.mjs
ADDED
|
@@ -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
|
+
});
|