skillrepo 4.3.0 → 4.5.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
@@ -179,14 +179,48 @@ One-shot fetch. Does NOT mutate your library or the server — just reads
179
179
  `GET /api/v1/skills/{owner}/{name}` and writes the skill to disk. Use
180
180
  this to preview or pin a specific skill without adding it to your library.
181
181
 
182
- ### `list` — show what's in your library
182
+ ### `list` — show what's in your library and what needs syncing
183
183
 
184
184
  ```sh
185
185
  skillrepo list [--json]
186
186
  ```
187
187
 
188
- Renders your library as a table: owner, name, version, description. Uses
189
- the same cached ETag as `update`.
188
+ Renders your library as a table with a per-row `Local` column showing
189
+ on-disk drift state for each detected vendor:
190
+
191
+ - `OK` — local copy matches the last sync.
192
+ - `STALE` — library has a newer version than what's on disk.
193
+ - `MISS` — library has the skill but no on-disk placement (or no sync
194
+ history yet — run `skillrepo update` to establish a baseline).
195
+ - `EDIT` — local files have been modified since the last sync (different
196
+ SHA than what was persisted).
197
+
198
+ When multiple vendors are detected, the column shows a worst-state-wins
199
+ rollup (`missing > edited > stale > current`). The `--json` output
200
+ includes a `placements[]` array per item with per-vendor states for
201
+ scripts that want the full breakdown.
202
+
203
+ A footer reports library-level sync state:
204
+
205
+ - `library in sync — local skills up to date` — everything is current.
206
+ - `library in sync — but N skills show local drift` — library hasn't
207
+ changed but some local placements need attention.
208
+ - `library has changed since last sync` — registry has new content; run
209
+ `skillrepo update`.
210
+ - `No sync history on this machine` — fresh install or
211
+ baseline-less state; run `skillrepo update`.
212
+
213
+ Glyphs (`✓` / `⚠`) are used in TTY contexts; ASCII fallbacks (`OK` /
214
+ `[!]` / `STALE` / `MISS` / `EDIT`) appear when stdout is not a TTY or
215
+ `NO_COLOR` is set, so piped output stays clean.
216
+
217
+ `--json` is a bare array of skill objects with the additional fields
218
+ `state` (rollup) and `placements[]`. Existing scripts that consume
219
+ the pre-#1555 `--json` shape keep working — the additions are purely
220
+ per-item, no top-level wrapper.
221
+
222
+ Uses the same cached ETag as `update` for the library-level footer
223
+ state.
190
224
 
191
225
  ### `search` — explore the registry
192
226
 
@@ -219,6 +253,109 @@ DELETEs from `/api/v1/library` and deletes the local directory. Requires
219
253
  a write-scoped access key. The local delete is immediate and does not
220
254
  wait for a follow-up sync.
221
255
 
256
+ ### `push` — upload a local skill directory to your account
257
+
258
+ ```sh
259
+ skillrepo push <path> [--idempotency-key <key>] [--key <key>] [--url <url>] [--json]
260
+ ```
261
+
262
+ Multipart `POST` to `/api/v1/library`. Walks `<path>`, packages
263
+ `SKILL.md` plus every supporting file under `scripts/`, `references/`,
264
+ and `assets/`, and uploads them as a single request. The server picks
265
+ the outcome by SKILL.md frontmatter `name`:
266
+
267
+ - **First push** of this name → creates a private skill and returns
268
+ `action: "created"`.
269
+ - **Subsequent push with changed content** → releases a new version.
270
+ The server classifies the bump as major (if `description`,
271
+ `allowed-tools`, or `compatibility` changed) or minor otherwise, and
272
+ returns `action: "updated"` with the bump kind.
273
+ - **Identical content** → no-op (SHA-matched). Returns
274
+ `action: "unchanged"`.
275
+
276
+ `push` is the only CLI command that uploads local content; it does not
277
+ write anything back to disk. Use `update` to pull canonical skills
278
+ from the server to your local library.
279
+
280
+ Limits: total multipart body ≤ 4.5 MB, per-file path depth ≤ 5
281
+ segments, executable/archive extensions blocked (full list in
282
+ `src/lib/skills/constants.ts`). Anti-abuse rate limit: 5/min and
283
+ 30/hr on Publisher; 30/min and 500/hr on Team.
284
+
285
+ Flags:
286
+
287
+ - `--idempotency-key <key>` — explicit key for safe retries. By default
288
+ the CLI generates a fresh UUID per invocation and uses it for the
289
+ in-process retry loop (transient 5xx/429 get up to 3 attempts with
290
+ exponential backoff and jitter). Pass an explicit key to share it
291
+ across separate shell invocations (CI step retries, manual reruns)
292
+ and replay the cached response. Cached responses live for 24 hours.
293
+ - `--json` — emit a structured object (`action`, `bump`, `owner`,
294
+ `name`, `version`, `filesUploaded`).
295
+ - `--key`, `--url` — standard auth / endpoint flags.
296
+
297
+ Exit codes: `5` (validation — bad SKILL.md, blocked path, payload too
298
+ large, `plan_limit`), `4` (scope — read-only key), `2` (auth), `1`
299
+ (network/5xx after retries).
300
+
301
+ ### `publish` — make one of your skills visible in the public catalog
302
+
303
+ ```sh
304
+ skillrepo publish <@owner/name> [--key <key>] [--url <url>] [--json]
305
+ ```
306
+
307
+ `POST`s to `/api/v1/library/{owner}/{name}/publish` to flip the
308
+ skill's visibility from `private` to `global`. Idempotent: calling
309
+ `publish` on an already-global skill returns `200` with
310
+ `action: "unchanged"` (the CLI prints "Already published").
311
+ Requires a write-scoped access key.
312
+
313
+ Permission model: admin+ members can always publish. Non-admin members
314
+ can publish if their account-membership has `canPublish: true` (or the
315
+ account-wide `memberCanPublish` default is enabled). Without either,
316
+ the CLI exits `4 / scope` with `code: publish_not_permitted`. The same
317
+ `canPublish` capability gates `unpublish` and `delete` symmetrically.
318
+
319
+ Publish-only preconditions that surface as exit `5 / validation`:
320
+
321
+ - `namespace_unset` — the account's name still equals its
322
+ auto-generated slug; customize the namespace first.
323
+ - `analysis_pending` — safety analysis hasn't completed (only fires
324
+ where analysis is enabled).
325
+ - `safety_grade_too_low` — the skill's safety grade is `F`.
326
+
327
+ Flags:
328
+
329
+ - `--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.
330
+ - `--key`, `--url` — standard auth / endpoint flags.
331
+
332
+ ### `unpublish` — remove one of your skills from the public catalog
333
+
334
+ ```sh
335
+ skillrepo unpublish <@owner/name> [--key <key>] [--url <url>] [--json]
336
+ ```
337
+
338
+ `POST`s to `/api/v1/library/{owner}/{name}/unpublish` to flip the
339
+ skill's visibility from `global` to `private`. Pair to `publish`:
340
+ same auth, same scope, same flag surface.
341
+
342
+ **Subscribers keep their copy.** Accounts that already had your skill
343
+ in their library retain the version they pulled — they just stop
344
+ receiving future updates unless you republish. The CLI surfaces a
345
+ one-line summary (e.g. "Notified 12 subscribers") so you know how
346
+ many accounts the unpublish reached.
347
+
348
+ Each affected subscriber account's owner-role member(s) receive a
349
+ one-time notification email, debounced 24h per `(skill, account)`
350
+ pair so a fast unpublish/republish/unpublish cycle doesn't spam the
351
+ same recipient. The publisher's own account is excluded.
352
+ Already-private skills (`action: "unchanged"`) trigger no emails.
353
+
354
+ Flags:
355
+
356
+ - `--json` — emit `{ action, owner, name, notifiedSubscriberCount }`. `notifiedSubscriberCount` is `0` when `action === "unchanged"`. No `ok` field — exit code carries success/failure.
357
+ - `--key`, `--url` — standard auth / endpoint flags.
358
+
222
359
  ### `session-sync` — auto-sync on Claude Code session start
223
360
 
224
361
  ```sh
@@ -371,8 +508,28 @@ Two scenarios worth calling out:
371
508
  | `SKILLREPO_ACCESS_KEY` | Access key for any command. Takes precedence over the config file but not CLI flags. |
372
509
  | `SKILLREPO_URL` | Server URL. Same precedence as above. |
373
510
  | `SKILLREPO_TIMEOUT_MS` | Per-request fetch timeout in milliseconds (default 30000). Set to `0` to disable. |
511
+ | `SKILLREPO_NO_UPDATE_CHECK` | Set to any non-empty truthy value (`1`, `yes`, `true`) to disable the post-command npm-registry self-staleness check. The check otherwise runs at most once per 24 hours, hits `registry.npmjs.org/skillrepo/latest`, and prints a one-line upgrade hint to stderr if a newer version is available. Auto-disabled when `CI=true`. |
374
512
  | `NO_COLOR` | Set any non-empty value to disable ANSI color in CLI output. |
375
513
 
514
+ ### Update nudge
515
+
516
+ After every command that isn't `--json`, the CLI does a best-effort
517
+ check against `https://registry.npmjs.org/skillrepo/latest` and prints
518
+ a one-line hint on stderr when a newer version is available:
519
+
520
+ ```
521
+ A newer skillrepo is available: 4.3.0 → 4.5.0
522
+ Upgrade: npm install -g skillrepo@latest
523
+ ```
524
+
525
+ The check is cached at `~/.claude/skillrepo/.npm-version-check` for
526
+ 24 hours on success and 1 hour on failure, has a 2-second fetch
527
+ timeout, and is fire-and-forget — every failure mode (network error,
528
+ non-2xx, parse failure, read-only FS) is swallowed silently. Set
529
+ `SKILLREPO_NO_UPDATE_CHECK=1` to disable. `CI=true` auto-disables.
530
+ Output is suppressed entirely when `--json` is on the parent command,
531
+ so structured pipelines aren't disturbed.
532
+
376
533
  ### Skill placement
377
534
 
378
535
  Skills land at one of two project paths, depending on the agent:
package/bin/skillrepo.mjs CHANGED
@@ -35,6 +35,8 @@ import { runSearch } from "../src/commands/search.mjs";
35
35
  import { runUninstall } from "../src/commands/uninstall.mjs";
36
36
  import { runSessionSync } from "../src/commands/session-sync.mjs";
37
37
  import { CliError, EXIT_OK, EXIT_VALIDATION } from "../src/lib/errors.mjs";
38
+ import { checkForCliUpdate } from "../src/lib/npm-update-check.mjs";
39
+ import { getCliVersion } from "../src/lib/cli-version.mjs";
38
40
 
39
41
  // ── Command registry ────────────────────────────────────────────────────
40
42
 
@@ -151,6 +153,49 @@ async function main(command, fullArgv) {
151
153
  }
152
154
 
153
155
  await COMMANDS[command].run(rest);
156
+
157
+ // After the primary command completes, do a best-effort npm-registry
158
+ // staleness check (#1554). Never blocks exit beyond a single tick:
159
+ // `Promise.race` against `setTimeout(0)` means if the fetch hasn't
160
+ // completed yet, we drop it and the user gets the nudge on the next
161
+ // invocation (when the cache file is more likely to be fresh).
162
+ //
163
+ // Suppressed entirely when the parent argv carries `--json` so we
164
+ // never inject text into a structured stream — not even on stderr,
165
+ // because some pipelines tee both streams.
166
+ if (!rest.includes("--json")) {
167
+ await runUpdateCheck();
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Best-effort update check. On a CACHE HIT this resolves synchronously
173
+ * (within a microtask) and we emit the nudge before the 0ms timer
174
+ * fires — that's the steady-state behavior after the first invocation.
175
+ *
176
+ * On a cache miss we race against `setTimeout(0)` so the user's primary
177
+ * command exit isn't held up by the nudge logic when they don't have
178
+ * a cached answer yet. The in-flight fetch is INTENTIONALLY left
179
+ * running after the race resolves: it has its own 2s `AbortController`
180
+ * (inside `checkForCliUpdate`) and is silent on failure, so the worst
181
+ * case is the process appearing to take slightly longer on its very
182
+ * first invocation while the cache warms. Every subsequent invocation
183
+ * benefits from the 24h positive cache and exits immediately.
184
+ *
185
+ * Errors are swallowed at the source by `checkForCliUpdate` itself; the
186
+ * outer `.catch` is belt-and-braces for the rare case where loading
187
+ * the module synchronously throws.
188
+ */
189
+ async function runUpdateCheck() {
190
+ let currentVersion;
191
+ try {
192
+ currentVersion = getCliVersion();
193
+ } catch {
194
+ return;
195
+ }
196
+ const checker = checkForCliUpdate({ currentVersion }).catch(() => {});
197
+ const yieldOnce = new Promise((resolve) => setTimeout(resolve, 0));
198
+ await Promise.race([checker, yieldOnce]);
154
199
  }
155
200
 
156
201
  // ── Help printing ───────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.3.0",
3
+ "version": "4.5.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": {
@@ -26,6 +26,7 @@
26
26
  "license": "SEE LICENSE IN LICENSE",
27
27
  "dependencies": {
28
28
  "cli-table3": "^0.6.5",
29
- "gray-matter": "^4.0.3"
29
+ "gray-matter": "^4.0.3",
30
+ "semver": "^7.6.3"
30
31
  }
31
32
  }
@@ -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