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/README.md CHANGED
@@ -219,6 +219,109 @@ DELETEs from `/api/v1/library` and deletes the local directory. Requires
219
219
  a write-scoped access key. The local delete is immediate and does not
220
220
  wait for a follow-up sync.
221
221
 
222
+ ### `push` — upload a local skill directory to your account
223
+
224
+ ```sh
225
+ skillrepo push <path> [--idempotency-key <key>] [--key <key>] [--url <url>] [--json]
226
+ ```
227
+
228
+ Multipart `POST` to `/api/v1/library`. Walks `<path>`, packages
229
+ `SKILL.md` plus every supporting file under `scripts/`, `references/`,
230
+ and `assets/`, and uploads them as a single request. The server picks
231
+ the outcome by SKILL.md frontmatter `name`:
232
+
233
+ - **First push** of this name → creates a private skill and returns
234
+ `action: "created"`.
235
+ - **Subsequent push with changed content** → releases a new version.
236
+ The server classifies the bump as major (if `description`,
237
+ `allowed-tools`, or `compatibility` changed) or minor otherwise, and
238
+ returns `action: "updated"` with the bump kind.
239
+ - **Identical content** → no-op (SHA-matched). Returns
240
+ `action: "unchanged"`.
241
+
242
+ `push` is the only CLI command that uploads local content; it does not
243
+ write anything back to disk. Use `update` to pull canonical skills
244
+ from the server to your local library.
245
+
246
+ Limits: total multipart body ≤ 4.5 MB, per-file path depth ≤ 5
247
+ segments, executable/archive extensions blocked (full list in
248
+ `src/lib/skills/constants.ts`). Anti-abuse rate limit: 5/min and
249
+ 30/hr on Publisher; 30/min and 500/hr on Team.
250
+
251
+ Flags:
252
+
253
+ - `--idempotency-key <key>` — explicit key for safe retries. By default
254
+ the CLI generates a fresh UUID per invocation and uses it for the
255
+ in-process retry loop (transient 5xx/429 get up to 3 attempts with
256
+ exponential backoff and jitter). Pass an explicit key to share it
257
+ across separate shell invocations (CI step retries, manual reruns)
258
+ and replay the cached response. Cached responses live for 24 hours.
259
+ - `--json` — emit a structured object (`action`, `bump`, `owner`,
260
+ `name`, `version`, `filesUploaded`).
261
+ - `--key`, `--url` — standard auth / endpoint flags.
262
+
263
+ Exit codes: `5` (validation — bad SKILL.md, blocked path, payload too
264
+ large, `plan_limit`), `4` (scope — read-only key), `2` (auth), `1`
265
+ (network/5xx after retries).
266
+
267
+ ### `publish` — make one of your skills visible in the public catalog
268
+
269
+ ```sh
270
+ skillrepo publish <@owner/name> [--key <key>] [--url <url>] [--json]
271
+ ```
272
+
273
+ `POST`s to `/api/v1/library/{owner}/{name}/publish` to flip the
274
+ skill's visibility from `private` to `global`. Idempotent: calling
275
+ `publish` on an already-global skill returns `200` with
276
+ `action: "unchanged"` (the CLI prints "Already published").
277
+ Requires a write-scoped access key.
278
+
279
+ Permission model: admin+ members can always publish. Non-admin members
280
+ can publish if their account-membership has `canPublish: true` (or the
281
+ account-wide `memberCanPublish` default is enabled). Without either,
282
+ the CLI exits `4 / scope` with `code: publish_not_permitted`. The same
283
+ `canPublish` capability gates `unpublish` and `delete` symmetrically.
284
+
285
+ Publish-only preconditions that surface as exit `5 / validation`:
286
+
287
+ - `namespace_unset` — the account's name still equals its
288
+ auto-generated slug; customize the namespace first.
289
+ - `analysis_pending` — safety analysis hasn't completed (only fires
290
+ where analysis is enabled).
291
+ - `safety_grade_too_low` — the skill's safety grade is `F`.
292
+
293
+ Flags:
294
+
295
+ - `--json` — emit `{ action, owner, name }`. The CLI exits non-zero on error, so the presence of stdout JSON already implies success — there is no `ok` field.
296
+ - `--key`, `--url` — standard auth / endpoint flags.
297
+
298
+ ### `unpublish` — remove one of your skills from the public catalog
299
+
300
+ ```sh
301
+ skillrepo unpublish <@owner/name> [--key <key>] [--url <url>] [--json]
302
+ ```
303
+
304
+ `POST`s to `/api/v1/library/{owner}/{name}/unpublish` to flip the
305
+ skill's visibility from `global` to `private`. Pair to `publish`:
306
+ same auth, same scope, same flag surface.
307
+
308
+ **Subscribers keep their copy.** Accounts that already had your skill
309
+ in their library retain the version they pulled — they just stop
310
+ receiving future updates unless you republish. The CLI surfaces a
311
+ one-line summary (e.g. "Notified 12 subscribers") so you know how
312
+ many accounts the unpublish reached.
313
+
314
+ Each affected subscriber account's owner-role member(s) receive a
315
+ one-time notification email, debounced 24h per `(skill, account)`
316
+ pair so a fast unpublish/republish/unpublish cycle doesn't spam the
317
+ same recipient. The publisher's own account is excluded.
318
+ Already-private skills (`action: "unchanged"`) trigger no emails.
319
+
320
+ Flags:
321
+
322
+ - `--json` — emit `{ action, owner, name, notifiedSubscriberCount }`. `notifiedSubscriberCount` is `0` when `action === "unchanged"`. No `ok` field — exit code carries success/failure.
323
+ - `--key`, `--url` — standard auth / endpoint flags.
324
+
222
325
  ### `session-sync` — auto-sync on Claude Code session start
223
326
 
224
327
  ```sh
package/bin/skillrepo.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * SkillRepo CLI — pull-based command dispatcher (#674, part of #646).
5
5
  *
6
- * Routes seven commands: init, update, get, add, remove, list, search.
6
+ * Routes the user-facing commands: init, update, get, add, push,
7
+ * publish, unpublish, remove, list, search, uninstall, session-sync.
7
8
  *
8
9
  * PR1 ships only the dispatcher; command modules other than init are
9
10
  * stubbed and exit with a "not yet implemented" message. The existing
@@ -26,6 +27,8 @@ import { runUpdate } from "../src/commands/update.mjs";
26
27
  import { runGet } from "../src/commands/get.mjs";
27
28
  import { runAdd } from "../src/commands/add.mjs";
28
29
  import { runPush } from "../src/commands/push.mjs";
30
+ import { runPublish } from "../src/commands/publish.mjs";
31
+ import { runUnpublish } from "../src/commands/unpublish.mjs";
29
32
  import { runRemove } from "../src/commands/remove.mjs";
30
33
  import { runList } from "../src/commands/list.mjs";
31
34
  import { runSearch } from "../src/commands/search.mjs";
@@ -70,6 +73,16 @@ const COMMANDS = {
70
73
  "[--idempotency-key <key>] [--json]",
71
74
  run: async (argv) => runPush(argv),
72
75
  },
76
+ publish: {
77
+ description: "Make one of your skills visible in the public catalog",
78
+ usage: "skillrepo publish <@owner/name> [--json] [--key <key>] [--url <url>]",
79
+ run: async (argv) => runPublish(argv),
80
+ },
81
+ unpublish: {
82
+ description: "Remove one of your skills from the public catalog (subscribers keep their copy)",
83
+ usage: "skillrepo unpublish <@owner/name> [--json] [--key <key>] [--url <url>]",
84
+ run: async (argv) => runUnpublish(argv),
85
+ },
73
86
  remove: {
74
87
  description: "Remove a skill from your library and delete it locally",
75
88
  usage: "skillrepo remove <@owner/name> [--global] [--agent <list>] [--json]",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,7 +87,7 @@ import {
87
87
  claudeSkillsProjectRoot,
88
88
  agentsSkillsProjectRoot,
89
89
  } from "../lib/paths.mjs";
90
- import { promptWithBrowserOpen } from "../lib/prompt.mjs";
90
+ import { promptWithBrowserOpen, confirm } from "../lib/prompt.mjs";
91
91
  import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
92
92
  import {
93
93
  CliError,
@@ -96,6 +96,10 @@ import {
96
96
  EXIT_AUTH,
97
97
  } from "../lib/errors.mjs";
98
98
  import { cliAuthUrl } from "../lib/constants.mjs";
99
+ import {
100
+ reportInitFailure,
101
+ telemetryDisabledByEnv,
102
+ } from "../lib/telemetry.mjs";
99
103
 
100
104
  /**
101
105
  * Format the user-visible "configured X, Y" line for the init step-4
@@ -288,6 +292,38 @@ export async function runInit(argv, io = {}, deps = {}) {
288
292
 
289
293
  p.blank();
290
294
 
295
+ // Determine the telemetry preference for THIS init run (#1539).
296
+ // Priority: SKILLREPO_NO_TELEMETRY env var (always wins) → existing
297
+ // config flag (if set explicitly) → opt-out default of `true`
298
+ // (enabled). The env-var check is the only opt-out path that fires
299
+ // BEFORE the very first user interaction; everything else assumes
300
+ // the user is willing to receive the prompt below.
301
+ let telemetryEnabledForRun;
302
+ if (telemetryDisabledByEnv()) {
303
+ telemetryEnabledForRun = false;
304
+ } else if (
305
+ existingConfig &&
306
+ typeof existingConfig.telemetry === "boolean"
307
+ ) {
308
+ telemetryEnabledForRun = existingConfig.telemetry;
309
+ } else if (yes || !process.stdin.isTTY) {
310
+ // Non-interactive: take the opt-out-friendly default. Users in
311
+ // CI / scripts can pin `SKILLREPO_NO_TELEMETRY=1` to override.
312
+ // The `!isTTY` check protects existing CI patterns like
313
+ // `init --key sk_live_...` (no --yes) that worked before this
314
+ // prompt was added — they would hang on `readline.question()`
315
+ // waiting for stdin that never arrives.
316
+ telemetryEnabledForRun = true;
317
+ } else {
318
+ // First-init prompt — only fires when no preference is recorded,
319
+ // the env var didn't already opt out, AND stdin is a TTY. Y/n
320
+ // with Y default, so a bare Enter keeps telemetry enabled.
321
+ telemetryEnabledForRun = await confirm(
322
+ "Send anonymous error reports to help improve setup?",
323
+ true,
324
+ );
325
+ }
326
+
291
327
  // ── Step 2: Validate against the server ──────────────────────
292
328
  p.step(2, 7, "Validating key");
293
329
  let accountCtx;
@@ -315,8 +351,27 @@ export async function runInit(argv, io = {}, deps = {}) {
315
351
  if (!apiKey || !apiKey.startsWith("sk_live_")) {
316
352
  throw validationError("Invalid access key format.");
317
353
  }
318
- accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
354
+ try {
355
+ accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
356
+ } catch (retryErr) {
357
+ // The user re-pasted and validate still failed. This is the
358
+ // failure mode the diagnostic exercise surfaced — exactly the
359
+ // case telemetry exists for. Fire-and-forget; never throws.
360
+ void reportInitFailure({
361
+ serverUrl,
362
+ config: telemetryEnabledForRun ? { telemetry: true } : { telemetry: false },
363
+ stage: "post_paste_validate",
364
+ errorCode: retryErr instanceof CliError ? retryErr.exitCode : EXIT_AUTH,
365
+ });
366
+ throw retryErr;
367
+ }
319
368
  } else {
369
+ void reportInitFailure({
370
+ serverUrl,
371
+ config: telemetryEnabledForRun ? { telemetry: true } : { telemetry: false },
372
+ stage: "post_paste_validate",
373
+ errorCode: err instanceof CliError ? err.exitCode : EXIT_AUTH,
374
+ });
320
375
  throw err;
321
376
  }
322
377
  }
@@ -325,12 +380,15 @@ export async function runInit(argv, io = {}, deps = {}) {
325
380
 
326
381
  // ── Step 3: Write global config ──────────────────────────────
327
382
  p.step(3, 7, "Writing config");
383
+ // Persist the telemetry preference alongside the credentials so
384
+ // subsequent inits skip the prompt and honor the same choice (#1539).
328
385
  const configAction = writeConfig({
329
386
  apiKey,
330
387
  serverUrl,
331
388
  accountSlug: accountCtx.accountSlug,
332
389
  accountId: accountCtx.accountId,
333
390
  userId: accountCtx.userId,
391
+ telemetry: telemetryEnabledForRun,
334
392
  });
335
393
  p.success(`~/.claude/skillrepo/config.json ${configAction}`);
336
394
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `skillrepo publish <@owner/name>` — flip a skill's visibility from
3
+ * `private` to `global`, making it discoverable in the public catalog
4
+ * (Epic #1444 R3 #1456).
5
+ *
6
+ * Verb is RESERVED for visibility transitions. Releasing a new version
7
+ * happens via `skillrepo push <path>`. The `publish` and `unpublish`
8
+ * pair is symmetric — same auth, same access-key scope, same flag
9
+ * surface — and shares an `setSkillVisibility` HTTP helper under
10
+ * the hood.
11
+ *
12
+ * Outcomes (mirror the server's discriminated `LibraryVisibilityResult`):
13
+ * - `published` → 200 OK, "✓ Published @owner/name…"
14
+ * - `unchanged` → 200 OK, "✓ Already published…"
15
+ * - `not-found` → exit 5 (EXIT_VALIDATION) with skill-not-found message
16
+ * - `forbidden` → exit 4 (EXIT_SCOPE) with the permission hint
17
+ * - `publish-blocked` → exit 5 (EXIT_VALIDATION) with the precondition reason
18
+ *
19
+ * Other 4xx/5xx flow through `mapErrorResponse` in `http.mjs` and
20
+ * surface as `authError` / `scopeError` / `networkError`.
21
+ *
22
+ * Flags: `--json --key --url`. NO disk write. NO `--global` /
23
+ * `--agent` (visibility transitions touch only the registry; local
24
+ * skill files are unaffected).
25
+ */
26
+
27
+ import { publishLibrarySkill } from "../lib/http.mjs";
28
+ import { resolveFlags } from "../lib/cli-config.mjs";
29
+ import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
30
+ import { validationError, scopeError } from "../lib/errors.mjs";
31
+
32
+ /**
33
+ * Run `publish`. Throws CliError on failure.
34
+ *
35
+ * @param {string[]} argv
36
+ * @param {object} [io]
37
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
38
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
39
+ */
40
+ export async function runPublish(argv, io = {}) {
41
+ const stdout = io.stdout ?? process.stdout;
42
+ let identifier = null;
43
+
44
+ const flags = resolveFlags(argv, {
45
+ acceptPositional(arg) {
46
+ if (identifier !== null) {
47
+ throw validationError(`Unexpected extra argument: ${arg}`, {
48
+ hint: "Pass exactly one @owner/name.",
49
+ });
50
+ }
51
+ identifier = arg;
52
+ return 1;
53
+ },
54
+ });
55
+
56
+ if (!identifier) {
57
+ throw validationError("Missing skill identifier.", {
58
+ hint: "Usage: skillrepo publish <@owner/name>",
59
+ });
60
+ }
61
+
62
+ const { owner, name } = parseIdentifier(identifier);
63
+ const ref = formatIdentifier({ owner, name });
64
+
65
+ const result = await publishLibrarySkill(flags.serverUrl, flags.apiKey, owner, name);
66
+
67
+ if (result.action === "not-found") {
68
+ throw validationError(`Skill ${ref} not found in your account.`, {
69
+ hint: "You can only publish skills that you own. Check `skillrepo list` for your skills.",
70
+ });
71
+ }
72
+
73
+ if (result.action === "forbidden") {
74
+ // exit 4 (EXIT_SCOPE) — same exit code as "missing API-key
75
+ // scope," semantically the closest match for "you're not
76
+ // permitted to do this." Scripts can distinguish via the `code`
77
+ // string in JSON output.
78
+ //
79
+ // Server `result.reason` already explains the entitlement model
80
+ // ("Admins, or members with the `canPublish` entitlement…").
81
+ // Hint must NOT repeat that text — just point at the next step
82
+ // the user takes, otherwise the terminal shows the same sentence
83
+ // twice (once as `error:`, once as `hint:`).
84
+ throw scopeError(result.reason, {
85
+ hint: "Ask an account admin to grant you the `canPublish` capability if you should have it.",
86
+ });
87
+ }
88
+
89
+ if (result.action === "publish-blocked") {
90
+ // 422 — product-rule precondition. The reason text from the server
91
+ // is already actionable (it tells the user what to do); pass it
92
+ // through verbatim. exit 5 (EXIT_VALIDATION) signals "the request
93
+ // was understood but cannot be processed in the current state."
94
+ throw validationError(result.reason);
95
+ }
96
+
97
+ if (flags.json) {
98
+ // Shape matches the rest of the CLI (`add`, `remove`, `push`):
99
+ // `action` is the success discriminator. No `ok: true` field —
100
+ // the CLI exits non-zero on error, so the presence of stdout
101
+ // JSON already implies success.
102
+ stdout.write(
103
+ JSON.stringify(
104
+ {
105
+ action: result.action,
106
+ owner,
107
+ name,
108
+ },
109
+ null,
110
+ 2,
111
+ ) + "\n",
112
+ );
113
+ return;
114
+ }
115
+
116
+ if (result.action === "unchanged") {
117
+ stdout.write(
118
+ `\n ✓ Already published — ${ref} is already in the public catalog.\n\n`,
119
+ );
120
+ return;
121
+ }
122
+
123
+ // result.action === "published"
124
+ stdout.write(`\n ✓ Published ${ref} — now in the public catalog.\n\n`);
125
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `skillrepo unpublish <@owner/name>` — flip a skill's visibility from
3
+ * `global` to `private`, removing it from the public catalog
4
+ * (Epic #1444 R3 #1456).
5
+ *
6
+ * Pair to `publish.mjs`. Same auth, same scope, same flag surface.
7
+ *
8
+ * Side-effect note (per the locked decision in #1446 — surface to
9
+ * the user via the success-line copy):
10
+ * - Subscribers KEEP their current copy of the skill on disk.
11
+ * - Subscribers stop receiving future updates unless the publisher
12
+ * re-publishes.
13
+ * - Each affected subscriber account's owner-role member(s) get
14
+ * an unpublish-notification email, debounced 24h per
15
+ * (skill, subscriber-account) pair.
16
+ *
17
+ * The success line for an actual unpublish includes
18
+ * `notifiedSubscriberCount` so the publisher knows how many accounts
19
+ * the unpublish reached.
20
+ *
21
+ * Outcomes:
22
+ * - `unpublished` → 200 OK, "✓ Unpublished @owner/name (notified N subscribers…)"
23
+ * - `unchanged` → 200 OK, "✓ Already private…"
24
+ * - `not-found` → exit 5 (EXIT_VALIDATION) with skill-not-found message
25
+ * - `forbidden` → exit 4 (EXIT_SCOPE) with the permission hint
26
+ *
27
+ * Unpublish has no product-rule preconditions of its own, so the
28
+ * `publish-blocked` outcome is never returned by the server here.
29
+ */
30
+
31
+ import { unpublishLibrarySkill } from "../lib/http.mjs";
32
+ import { resolveFlags } from "../lib/cli-config.mjs";
33
+ import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
34
+ import { validationError, scopeError } from "../lib/errors.mjs";
35
+
36
+ /**
37
+ * Run `unpublish`. Throws CliError on failure.
38
+ *
39
+ * @param {string[]} argv
40
+ * @param {object} [io]
41
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
42
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
43
+ */
44
+ export async function runUnpublish(argv, io = {}) {
45
+ const stdout = io.stdout ?? process.stdout;
46
+ let identifier = null;
47
+
48
+ const flags = resolveFlags(argv, {
49
+ acceptPositional(arg) {
50
+ if (identifier !== null) {
51
+ throw validationError(`Unexpected extra argument: ${arg}`, {
52
+ hint: "Pass exactly one @owner/name.",
53
+ });
54
+ }
55
+ identifier = arg;
56
+ return 1;
57
+ },
58
+ });
59
+
60
+ if (!identifier) {
61
+ throw validationError("Missing skill identifier.", {
62
+ hint: "Usage: skillrepo unpublish <@owner/name>",
63
+ });
64
+ }
65
+
66
+ const { owner, name } = parseIdentifier(identifier);
67
+ const ref = formatIdentifier({ owner, name });
68
+
69
+ const result = await unpublishLibrarySkill(flags.serverUrl, flags.apiKey, owner, name);
70
+
71
+ if (result.action === "not-found") {
72
+ throw validationError(`Skill ${ref} not found in your account.`, {
73
+ hint: "You can only unpublish skills that you own. Check `skillrepo list` for your skills.",
74
+ });
75
+ }
76
+
77
+ if (result.action === "forbidden") {
78
+ // exit 4 (EXIT_SCOPE) — same code as missing API-key scope.
79
+ // Server `result.reason` already names the entitlement. Hint
80
+ // stays short and action-only to avoid duplicating that text.
81
+ throw scopeError(result.reason, {
82
+ hint: "Ask an account admin to grant you the `canPublish` capability if you should have it.",
83
+ });
84
+ }
85
+
86
+ if (flags.json) {
87
+ // Shape matches the rest of the CLI: `action` is the success
88
+ // discriminator, no `ok` field. `notifiedSubscriberCount` is
89
+ // always present so scripts don't need conditional access.
90
+ stdout.write(
91
+ JSON.stringify(
92
+ {
93
+ action: result.action,
94
+ owner,
95
+ name,
96
+ notifiedSubscriberCount:
97
+ result.action === "unpublished" ? result.notifiedSubscriberCount : 0,
98
+ },
99
+ null,
100
+ 2,
101
+ ) + "\n",
102
+ );
103
+ return;
104
+ }
105
+
106
+ if (result.action === "unchanged") {
107
+ stdout.write(
108
+ `\n ✓ Already private — ${ref} is not in the public catalog.\n\n`,
109
+ );
110
+ return;
111
+ }
112
+
113
+ // result.action === "unpublished"
114
+ // Locked decision #1446: subscribers keep their copy; they stop
115
+ // getting updates. Surface that explicitly so the publisher knows
116
+ // the action was non-destructive for existing users.
117
+ const count = result.notifiedSubscriberCount;
118
+ if (count === 0) {
119
+ stdout.write(
120
+ `\n ✓ Unpublished ${ref} — no other accounts had it in their library, no notifications sent.\n\n`,
121
+ );
122
+ } else {
123
+ const subscribers = count === 1 ? "subscriber" : "subscribers";
124
+ stdout.write(
125
+ `\n ✓ Unpublished ${ref} — notified ${count} ${subscribers} ` +
126
+ `(they keep their current copy but won't receive future updates).\n\n`,
127
+ );
128
+ }
129
+ }
@@ -174,6 +174,12 @@ export function writeConfig(config) {
174
174
  for (const key of ["accountSlug", "accountId", "userId"]) {
175
175
  if (config[key] !== undefined) merged[key] = config[key];
176
176
  }
177
+ // Telemetry opt-in/out flag (#1539). Persisted only when the caller
178
+ // explicitly sets it — omitting means "no preference recorded,"
179
+ // which the telemetry module treats as "enabled" (opt-out default).
180
+ if (typeof config.telemetry === "boolean") {
181
+ merged.telemetry = config.telemetry;
182
+ }
177
183
 
178
184
  // Atomic write via temp-file + rename. Matches the file-write.mjs
179
185
  // pattern: the config file is never in a half-written state, so