skillrepo 4.2.0 → 4.4.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/src/lib/http.mjs CHANGED
@@ -1055,3 +1055,192 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
1055
1055
  pagination: body.pagination ?? { total: 0, limit: opts.limit ?? 20, offset: opts.offset ?? 0 },
1056
1056
  };
1057
1057
  }
1058
+
1059
+ // ── /api/v1/library/[owner]/[name]/publish + /unpublish (#1444 R3 #1449) ──
1060
+
1061
+ /**
1062
+ * @typedef {Object} VisibilityResultPublished
1063
+ * @property {"published"} action
1064
+ * @property {any} skill
1065
+ *
1066
+ * @typedef {Object} VisibilityResultUnpublished
1067
+ * @property {"unpublished"} action
1068
+ * @property {number} notifiedSubscriberCount
1069
+ * @property {any} skill
1070
+ *
1071
+ * @typedef {Object} VisibilityResultUnchanged
1072
+ * @property {"unchanged"} action
1073
+ * @property {any} skill
1074
+ *
1075
+ * @typedef {Object} VisibilityResultForbidden
1076
+ * @property {"forbidden"} action
1077
+ * @property {"publish_not_permitted" | "unpublish_not_permitted"} code
1078
+ * @property {string} reason
1079
+ *
1080
+ * @typedef {Object} VisibilityResultBlocked
1081
+ * @property {"publish-blocked"} action
1082
+ * @property {"namespace_unset" | "analysis_pending" | "safety_grade_too_low"} code
1083
+ * @property {string} reason
1084
+ *
1085
+ * @typedef {Object} VisibilityResultNotFound
1086
+ * @property {"not-found"} action
1087
+ * @property {string} owner
1088
+ * @property {string} name
1089
+ *
1090
+ * @typedef {VisibilityResultPublished | VisibilityResultUnpublished | VisibilityResultUnchanged
1091
+ * | VisibilityResultForbidden | VisibilityResultBlocked | VisibilityResultNotFound} VisibilityResult
1092
+ */
1093
+
1094
+ /**
1095
+ * Shared helper used by `publishLibrarySkill` and `unpublishLibrarySkill`.
1096
+ * The two endpoints differ only in:
1097
+ * - URL suffix (/publish vs /unpublish)
1098
+ * - 200 `action` value (published vs unpublished)
1099
+ * - 200 response includes `notifiedSubscriberCount` only on unpublish
1100
+ * - 403 `code` (publish_not_permitted vs unpublish_not_permitted)
1101
+ * - 422 `code` set differs (publish has product-rule blocked codes;
1102
+ * unpublish has only `idempotency_key_reused` which routes
1103
+ * through `mapErrorResponse`)
1104
+ *
1105
+ * Both return a discriminated `VisibilityResult` so the command layer
1106
+ * can map outcomes to user-facing exit codes without duplicating the
1107
+ * status-code dispatch.
1108
+ *
1109
+ * Idempotency-Key: auto-generated unless supplied via `options.idempotencyKey`.
1110
+ * `retry: true` is safe because the server caches responses for 24h
1111
+ * keyed by `(accountId, key, route)`.
1112
+ */
1113
+ async function setSkillVisibility(serverUrl, apiKey, owner, name, target, options = {}) {
1114
+ const action = target === "global" ? "publish" : "unpublish";
1115
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library/${encodeURIComponent(
1116
+ owner,
1117
+ )}/${encodeURIComponent(name)}/${action}`;
1118
+ const idempotencyKey = options.idempotencyKey ?? generateIdempotencyKey();
1119
+
1120
+ const res = await safeFetch(url, {
1121
+ method: "POST",
1122
+ headers: {
1123
+ ...(await authHeaders(apiKey)),
1124
+ "Idempotency-Key": idempotencyKey,
1125
+ },
1126
+ retry: true,
1127
+ });
1128
+
1129
+ if (res.status === 200) {
1130
+ const body = await parseJsonOrThrow(res, url);
1131
+ if (body?.action === "published") {
1132
+ return { action: "published", skill: body.skill };
1133
+ }
1134
+ if (body?.action === "unpublished") {
1135
+ return {
1136
+ action: "unpublished",
1137
+ notifiedSubscriberCount: body.notifiedSubscriberCount ?? 0,
1138
+ skill: body.skill,
1139
+ };
1140
+ }
1141
+ if (body?.action === "unchanged") {
1142
+ return { action: "unchanged", skill: body.skill };
1143
+ }
1144
+ throw networkError(
1145
+ `Library ${action} returned 200 with unexpected action "${body?.action}" from ${url}`,
1146
+ );
1147
+ }
1148
+
1149
+ if (res.status === 404) {
1150
+ await res.json().catch(() => undefined);
1151
+ return { action: "not-found", owner, name };
1152
+ }
1153
+
1154
+ // 403 / 422 require body inspection. Once we read the stream we
1155
+ // CANNOT fall through to `mapErrorResponse` (which re-reads the
1156
+ // body and would get an empty object — a `plan_limit` 403 would
1157
+ // mis-classify as authError exit 2 instead of validationError
1158
+ // exit 5 with billing hint). Pre-parse once, then dispatch every
1159
+ // code path inline mirroring `mapErrorResponse`'s shape so the
1160
+ // CliError types stay consistent across endpoints.
1161
+ if (res.status === 403 || res.status === 422) {
1162
+ let body = null;
1163
+ try {
1164
+ body = await res.json();
1165
+ } catch {
1166
+ // body stays null — fall through to the no-code branches below
1167
+ }
1168
+ const code = typeof body?.code === "string" ? body.code : null;
1169
+ const message = body?.error || `${res.status} ${res.statusText}`;
1170
+
1171
+ if (res.status === 403) {
1172
+ // Our endpoint-specific permission failure.
1173
+ if (code === "publish_not_permitted" || code === "unpublish_not_permitted") {
1174
+ return { action: "forbidden", code, reason: message };
1175
+ }
1176
+ // Mirror `mapErrorResponse`'s 403 taxonomy so a 403 here behaves
1177
+ // identically to a 403 on any other endpoint:
1178
+ // - `plan_limit` → validationError (exit 5) with billing hint
1179
+ // - any `*scope*` code → scopeError (exit 4)
1180
+ // - everything else → authError (exit 2, "suspended" hint)
1181
+ if (code === "plan_limit") {
1182
+ throw validationError(message, {
1183
+ hint: "Upgrade your plan at /app/settings/billing or remove an existing skill from your library.",
1184
+ });
1185
+ }
1186
+ if (code && code.toLowerCase().includes("scope")) {
1187
+ throw scopeError(message, {
1188
+ hint: "Create a write-scoped key at /app/settings/access-keys.",
1189
+ });
1190
+ }
1191
+ throw authError(message, {
1192
+ hint: "If your account is suspended, contact support.",
1193
+ });
1194
+ }
1195
+
1196
+ // res.status === 422
1197
+ if (target === "global") {
1198
+ if (
1199
+ code === "namespace_unset" ||
1200
+ code === "analysis_pending" ||
1201
+ code === "safety_grade_too_low"
1202
+ ) {
1203
+ return { action: "publish-blocked", code, reason: message };
1204
+ }
1205
+ }
1206
+ // `idempotency_key_reused` and any other 422 code surface as
1207
+ // validationError so the exit code matches the rest of v1.
1208
+ throw validationError(message);
1209
+ }
1210
+
1211
+ const err = await mapErrorResponse(res, url);
1212
+ if (err === null) {
1213
+ throw validationError(
1214
+ `Library ${action} returned ${res.status} with no documented meaning.`,
1215
+ );
1216
+ }
1217
+ throw err;
1218
+ }
1219
+
1220
+ /**
1221
+ * POST /api/v1/library/{owner}/{name}/publish (#1449).
1222
+ *
1223
+ * @param {string} serverUrl
1224
+ * @param {string} apiKey
1225
+ * @param {string} owner
1226
+ * @param {string} name
1227
+ * @param {{ idempotencyKey?: string }} [options]
1228
+ * @returns {Promise<VisibilityResult>}
1229
+ */
1230
+ export async function publishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
1231
+ return setSkillVisibility(serverUrl, apiKey, owner, name, "global", options);
1232
+ }
1233
+
1234
+ /**
1235
+ * POST /api/v1/library/{owner}/{name}/unpublish (#1449).
1236
+ *
1237
+ * @param {string} serverUrl
1238
+ * @param {string} apiKey
1239
+ * @param {string} owner
1240
+ * @param {string} name
1241
+ * @param {{ idempotencyKey?: string }} [options]
1242
+ * @returns {Promise<VisibilityResult>}
1243
+ */
1244
+ export async function unpublishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
1245
+ return setSkillVisibility(serverUrl, apiKey, owner, name, "private", options);
1246
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Anonymous CLI telemetry (#1539).
3
+ *
4
+ * The CLI ships zero telemetry by default before this module: when
5
+ * `skillrepo init` fails on a user's machine — paste error, terminal
6
+ * corruption, wrong server URL — nothing reaches the server. The
7
+ * resulting blindspot was the single biggest reason the live diagnostic
8
+ * surfaced in the #1535 epic took days rather than minutes.
9
+ *
10
+ * This module is the OPT-OUT-FRIENDLY counterpart to the server-side
11
+ * `/api/v1/cli/events` endpoint. It does ONE thing — report an init
12
+ * failure — and is designed so that nothing it does can possibly
13
+ * interfere with the user's actual init flow:
14
+ *
15
+ * 1. **Fire-and-forget.** The HTTP call is started without `await`;
16
+ * the calling site continues to the next line immediately.
17
+ * 2. **1-second hard timeout.** Even if the server hangs, the CLI
18
+ * pays at most 1 second of wall-clock for the call.
19
+ * 3. **Never throws.** Every failure mode (network, DNS, timeout,
20
+ * schema-rejection-by-server) is swallowed silently. The user's
21
+ * init flow continues to its own error reporting unchanged.
22
+ * 4. **No key material.** The payload schema is documented at
23
+ * `src/app/api/v1/cli/events/route.ts` — the server uses
24
+ * `.strict()` so any extra field is rejected with 400, and this
25
+ * module's `reportInitFailure` constructs the payload from a
26
+ * closed set of allowed fields rather than spreading a context
27
+ * object that could carry the user's key.
28
+ *
29
+ * Opt-out is honored from three sources, any one of which disables
30
+ * the call entirely:
31
+ *
32
+ * - `SKILLREPO_NO_TELEMETRY=1` (or any non-empty value) in the env.
33
+ * The simplest path for users who don't trust the CLI yet.
34
+ * - `telemetry: false` in the global config (`~/.claude/skillrepo/
35
+ * config.json`). Set interactively at first init, or by hand.
36
+ * - The legacy `DO_NOT_TRACK` env var (community convention from
37
+ * consoledonottrack.com). Honored for parity even though the
38
+ * SkillRepo-specific var is the documented one.
39
+ *
40
+ * Self-hosting note: this module sends data ONLY to the server URL
41
+ * the user explicitly configured via `skillrepo init`. Self-hosters
42
+ * who point at their own SkillRepo instance receive their own
43
+ * telemetry; no traffic leaves their boundary.
44
+ */
45
+
46
+ import { platform as nodePlatform } from "node:os";
47
+ import { getCliVersion } from "./cli-version.mjs";
48
+
49
+ /** Hard cap on how long the CLI waits for the telemetry call. */
50
+ export const TELEMETRY_TIMEOUT_MS = 1000;
51
+
52
+ /**
53
+ * Was telemetry explicitly disabled by env var? Independent of config
54
+ * so a user can disable telemetry without writing a config file (e.g.
55
+ * during the first-ever init, BEFORE the config file exists).
56
+ *
57
+ * Honors:
58
+ * - `SKILLREPO_NO_TELEMETRY` (any truthy value)
59
+ * - `DO_NOT_TRACK` (consoledonottrack.com convention; truthy = opt out)
60
+ *
61
+ * Exported for tests + for the init prompt to skip asking when the env
62
+ * var is already set.
63
+ */
64
+ export function telemetryDisabledByEnv() {
65
+ const skillrepo = process.env.SKILLREPO_NO_TELEMETRY;
66
+ if (skillrepo && skillrepo !== "0" && skillrepo.toLowerCase() !== "false") {
67
+ return true;
68
+ }
69
+ const dnt = process.env.DO_NOT_TRACK;
70
+ if (dnt === "1" || dnt?.toLowerCase() === "true") {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Is telemetry enabled given the env + a config object? Pure function
78
+ * — takes the config explicitly so it's testable without touching disk.
79
+ * Default is enabled (opt-out, not opt-in) — the user is asked at first
80
+ * init and the prompt's "y" answer is what writes `telemetry: true` to
81
+ * config. Missing-config means brand-new install: the call is allowed
82
+ * (the init flow will land the config on success and persist whatever
83
+ * the user picked at the prompt).
84
+ */
85
+ export function telemetryEnabled(config) {
86
+ if (telemetryDisabledByEnv()) return false;
87
+ // `telemetry: false` explicitly opts out. `telemetry: true` or
88
+ // missing both default to enabled.
89
+ if (config && config.telemetry === false) return false;
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Closed set of init stages. Mirrors the server-side `CliEventBody`
95
+ * Zod schema — sending a value outside this set would be rejected by
96
+ * the server's `.strict()` validator and quietly swallowed by this
97
+ * module, so we constrain at the source.
98
+ */
99
+ export const INIT_STAGES = Object.freeze([
100
+ "pre_paste",
101
+ "post_paste_validate",
102
+ "config_write",
103
+ "library_sync",
104
+ "agent_detection",
105
+ ]);
106
+
107
+ /**
108
+ * Closed set of platforms (mirrors server-side Zod enum). Unknown
109
+ * Node.js `os.platform()` returns are mapped to the closest match or
110
+ * dropped from the payload — the server would reject 'sunos' etc.
111
+ */
112
+ const SUPPORTED_PLATFORMS = new Set([
113
+ "darwin",
114
+ "linux",
115
+ "win32",
116
+ "freebsd",
117
+ "openbsd",
118
+ "aix",
119
+ ]);
120
+
121
+ /**
122
+ * Extract the URL host from a server URL, with no path/query/userinfo.
123
+ * The server's Zod schema rejects anything with a slash or `@`, so a
124
+ * malformed input would fail validation and be silently swallowed —
125
+ * extracting the host on the CLI side keeps the payload clean and
126
+ * matches the privacy contract (no full URLs in payloads).
127
+ */
128
+ function hostnameFrom(serverUrl) {
129
+ try {
130
+ return new URL(serverUrl).host;
131
+ } catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Report an init failure to the server. Fire-and-forget — the returned
138
+ * promise is the abort/timeout housekeeping, NOT a result the caller
139
+ * should await. Tests await it to assert behavior; production call
140
+ * sites do `void reportInitFailure(...)` and continue immediately.
141
+ *
142
+ * @param {object} params
143
+ * @param {string} params.serverUrl - The SkillRepo server URL the user
144
+ * was trying to init against (used to compute `serverUrlHost`).
145
+ * @param {object|null} params.config - The current global config
146
+ * (or null if no config exists yet). Determines opt-out state.
147
+ * @param {string} params.stage - One of `INIT_STAGES`.
148
+ * @param {number} params.errorCode - CLI exit code or HTTP status.
149
+ * @param {object} [params.deps] - Test seam — inject `fetch` and `now`.
150
+ */
151
+ export async function reportInitFailure({
152
+ serverUrl,
153
+ config,
154
+ stage,
155
+ errorCode,
156
+ deps,
157
+ }) {
158
+ if (!telemetryEnabled(config)) return;
159
+ if (!INIT_STAGES.includes(stage)) return; // Closed enum: silently drop.
160
+
161
+ const platform = nodePlatform();
162
+ if (!SUPPORTED_PLATFORMS.has(platform)) return; // Server would 400.
163
+
164
+ const cliVersion = getCliVersion();
165
+ const payload = {
166
+ stage,
167
+ errorCode,
168
+ cliVersion,
169
+ nodeVersion: process.version,
170
+ platform,
171
+ serverUrlHost: hostnameFrom(serverUrl),
172
+ };
173
+
174
+ // Allow a test-injected fetch (jsdom doesn't have a global fetch by
175
+ // default in some setups, and we want to assert the call shape
176
+ // without hitting the network). Production uses Node's built-in
177
+ // fetch.
178
+ const fetchImpl = (deps && deps.fetch) || globalThis.fetch;
179
+ if (typeof fetchImpl !== "function") return; // Defensive — should never happen on Node ≥18.
180
+
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
183
+
184
+ try {
185
+ await fetchImpl(`${serverUrl.replace(/\/+$/, "")}/api/v1/cli/events`, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json",
189
+ "User-Agent": `skillrepo-cli/${cliVersion} telemetry`,
190
+ },
191
+ body: JSON.stringify(payload),
192
+ signal: controller.signal,
193
+ });
194
+ } catch {
195
+ // Silent — telemetry must NEVER raise into the user's init flow.
196
+ // The user already sees the underlying init failure; piling a
197
+ // "telemetry call failed" warning on top would erode trust.
198
+ } finally {
199
+ clearTimeout(timeoutId);
200
+ }
201
+ }
@@ -233,6 +233,91 @@ describe("runInit — credential resolution", () => {
233
233
  });
234
234
  });
235
235
 
236
+ // ── Telemetry consent wiring (#1539, #1535 round-1 audit) ──────────────
237
+ //
238
+ // The first-init consent prompt has four branches:
239
+ // 1. SKILLREPO_NO_TELEMETRY env var → telemetryEnabledForRun=false
240
+ // 2. existing config has explicit telemetry flag → use that
241
+ // 3. --yes OR non-TTY stdin → opt-out-friendly default (true)
242
+ // 4. interactive TTY → confirm() prompt
243
+ //
244
+ // All tests run under `--yes` (process.stdin.isTTY is falsy in node:test
245
+ // regardless), so they exercise branches 1, 2, and 3. Branch 4 (TTY
246
+ // interactive prompt) is integration-level — not unit-testable without
247
+ // a pty harness.
248
+
249
+ describe("runInit — telemetry consent (#1539)", () => {
250
+ let originalNoTelemetry;
251
+ let originalDoNotTrack;
252
+
253
+ beforeEach(async () => {
254
+ await setup();
255
+ originalNoTelemetry = process.env.SKILLREPO_NO_TELEMETRY;
256
+ originalDoNotTrack = process.env.DO_NOT_TRACK;
257
+ delete process.env.SKILLREPO_NO_TELEMETRY;
258
+ delete process.env.DO_NOT_TRACK;
259
+ });
260
+
261
+ afterEach(async () => {
262
+ if (originalNoTelemetry === undefined) {
263
+ delete process.env.SKILLREPO_NO_TELEMETRY;
264
+ } else {
265
+ process.env.SKILLREPO_NO_TELEMETRY = originalNoTelemetry;
266
+ }
267
+ if (originalDoNotTrack === undefined) {
268
+ delete process.env.DO_NOT_TRACK;
269
+ } else {
270
+ process.env.DO_NOT_TRACK = originalDoNotTrack;
271
+ }
272
+ await teardown();
273
+ });
274
+
275
+ it("--yes (no existing config, no env var) defaults telemetry to true", async () => {
276
+ await runInit(
277
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
278
+ { stdout, stderr },
279
+ );
280
+ const cfg = readConfig();
281
+ assert.equal(cfg.telemetry, true);
282
+ });
283
+
284
+ it("SKILLREPO_NO_TELEMETRY=1 short-circuits to telemetry=false (even under --yes)", async () => {
285
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
286
+ await runInit(
287
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
288
+ { stdout, stderr },
289
+ );
290
+ const cfg = readConfig();
291
+ assert.equal(cfg.telemetry, false);
292
+ });
293
+
294
+ it("DO_NOT_TRACK=1 (community convention) short-circuits to telemetry=false", async () => {
295
+ process.env.DO_NOT_TRACK = "1";
296
+ await runInit(
297
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
298
+ { stdout, stderr },
299
+ );
300
+ const cfg = readConfig();
301
+ assert.equal(cfg.telemetry, false);
302
+ });
303
+
304
+ it("existing config.telemetry=false is preserved across re-init", async () => {
305
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
306
+ writeFileSync(
307
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
308
+ JSON.stringify({
309
+ schemaVersion: 1,
310
+ apiKey: VALID_KEY,
311
+ serverUrl,
312
+ telemetry: false,
313
+ }),
314
+ );
315
+ await runInit(["--yes"], { stdout, stderr });
316
+ const cfg = readConfig();
317
+ assert.equal(cfg.telemetry, false);
318
+ });
319
+ });
320
+
236
321
  // ── Error paths ────────────────────────────────────────────────────────
237
322
 
238
323
  describe("runInit — error paths", () => {