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 +103 -0
- package/bin/skillrepo.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +60 -2
- package/src/commands/publish.mjs +125 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/config.mjs +6 -0
- package/src/lib/http.mjs +189 -0
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/publish.test.mjs +420 -0
- package/src/test/e2e/mock-server.mjs +110 -0
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/telemetry.test.mjs +289 -0
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
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|