skillrepo 4.2.0 → 4.3.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/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.3.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": {
@@ -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/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,420 @@
1
+ /**
2
+ * Unit / integration tests for `runPublish` and `runUnpublish` —
3
+ * Epic #1444 R3 #1456.
4
+ *
5
+ * Tests both verbs in one file because they share the
6
+ * `setSkillVisibility` HTTP helper and have symmetric outcome
7
+ * mappings (only the response shape and 403 code differ). Each
8
+ * test exercises a distinct outcome of the discriminated
9
+ * `VisibilityResult` union returned by the HTTP layer.
10
+ */
11
+
12
+ import { describe, it, beforeEach, afterEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import { runPublish } from "../../commands/publish.mjs";
19
+ import { runUnpublish } from "../../commands/unpublish.mjs";
20
+ import {
21
+ CliError,
22
+ EXIT_VALIDATION,
23
+ EXIT_AUTH,
24
+ EXIT_SCOPE,
25
+ } from "../../lib/errors.mjs";
26
+ import { createMockServer } from "../e2e/mock-server.mjs";
27
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
28
+ import {
29
+ captureHome,
30
+ setSandboxHome,
31
+ restoreHome,
32
+ } from "../helpers/sandbox-home.mjs";
33
+
34
+ let sandbox;
35
+ let server;
36
+ let serverUrl;
37
+ let originalCwd;
38
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
39
+ let originalHomeEnv;
40
+ let stdout;
41
+ const VALID_KEY = "sk_live_test";
42
+
43
+ async function setup() {
44
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-pub-"));
45
+ mkdirSync(join(sandbox, "project"), { recursive: true });
46
+ mkdirSync(join(sandbox, "home"), { recursive: true });
47
+ originalCwd = process.cwd();
48
+ originalHomeEnv = captureHome();
49
+ process.chdir(join(sandbox, "project"));
50
+ setSandboxHome(join(sandbox, "home"));
51
+ delete process.env.SKILLREPO_ACCESS_KEY;
52
+
53
+ server = createMockServer({});
54
+ const port = await server.start();
55
+ serverUrl = `http://127.0.0.1:${port}`;
56
+
57
+ stdout = createCaptureStream();
58
+ }
59
+
60
+ async function teardown() {
61
+ if (server) await server.stop();
62
+ process.chdir(originalCwd);
63
+ restoreHome(originalHomeEnv);
64
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
65
+ server = null;
66
+ }
67
+
68
+ // argv used by both verbs — identifier first, then auth + endpoint
69
+ // flags. Caller can append extra flags (e.g. `--json`) at the end.
70
+ const baseArgv = (...extra) => [
71
+ `@alice/my-skill`,
72
+ "--key",
73
+ VALID_KEY,
74
+ "--url",
75
+ serverUrl,
76
+ ...extra,
77
+ ];
78
+
79
+ // ── runPublish ──────────────────────────────────────────────────────────
80
+
81
+ describe("runPublish — happy path", () => {
82
+ beforeEach(setup);
83
+ afterEach(teardown);
84
+
85
+ it("200 published → exit 0, prints '✓ Published @owner/name'", async () => {
86
+ // Default mock-server response is 200 published with synthesised
87
+ // SyncSkill — no slot configuration needed.
88
+ await runPublish(baseArgv(), { stdout });
89
+ const out = stdout.text();
90
+ // Matches the established `✓ <verb>` shape used by add/remove/push.
91
+ assert.match(out, /✓ Published @alice\/my-skill/);
92
+ assert.match(out, /now in the public catalog/);
93
+ });
94
+
95
+ it("200 unchanged → exit 0, prints '✓ Already published'", async () => {
96
+ server.setPublishResponse("alice", "my-skill", {
97
+ status: 200,
98
+ body: {
99
+ action: "unchanged",
100
+ skill: { owner: "alice", name: "my-skill" },
101
+ },
102
+ });
103
+ await runPublish(baseArgv(), { stdout });
104
+ assert.match(stdout.text(), /✓ Already published/);
105
+ });
106
+
107
+ it("--json emits a structured success payload (no `ok` field — matches add/remove/push convention)", async () => {
108
+ await runPublish(baseArgv("--json"), { stdout });
109
+ const parsed = JSON.parse(stdout.text());
110
+ // `action` is the success discriminator. NO `ok` field — the
111
+ // CLI exits non-zero on error, so the presence of stdout JSON
112
+ // already implies success. Matches add.mjs / remove.mjs /
113
+ // push.mjs shapes.
114
+ assert.equal(parsed.ok, undefined);
115
+ assert.equal(parsed.action, "published");
116
+ assert.equal(parsed.owner, "alice");
117
+ assert.equal(parsed.name, "my-skill");
118
+ });
119
+ });
120
+
121
+ describe("runPublish — error paths", () => {
122
+ beforeEach(setup);
123
+ afterEach(teardown);
124
+
125
+ it("missing identifier → CliError EXIT_VALIDATION", async () => {
126
+ await assert.rejects(
127
+ runPublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
128
+ (err) =>
129
+ err instanceof CliError &&
130
+ err.exitCode === EXIT_VALIDATION &&
131
+ /Missing skill identifier/.test(err.message),
132
+ );
133
+ });
134
+
135
+ it("404 not-found → CliError EXIT_VALIDATION with helpful hint", async () => {
136
+ server.setPublishResponse("alice", "my-skill", {
137
+ status: 404,
138
+ body: { error: "Skill not found", code: "skill_not_found" },
139
+ });
140
+ await assert.rejects(
141
+ runPublish(baseArgv(), { stdout }),
142
+ (err) =>
143
+ err instanceof CliError &&
144
+ err.exitCode === EXIT_VALIDATION &&
145
+ /not found in your account/.test(err.message),
146
+ );
147
+ });
148
+
149
+ it("403 publish_not_permitted → CliError EXIT_SCOPE with canPublish hint", async () => {
150
+ server.setPublishResponse("alice", "my-skill", {
151
+ status: 403,
152
+ body: {
153
+ error: "You do not have permission to publish this skill.",
154
+ code: "publish_not_permitted",
155
+ },
156
+ });
157
+ await assert.rejects(
158
+ runPublish(baseArgv(), { stdout }),
159
+ (err) =>
160
+ err instanceof CliError &&
161
+ err.exitCode === EXIT_SCOPE &&
162
+ /canPublish/.test(err.hint ?? ""),
163
+ );
164
+ });
165
+
166
+ it("403 hint does NOT duplicate the server's error reason text", async () => {
167
+ // Regression guard: pre-PR-F-review the CLI hint repeated the
168
+ // entitlement explanation that the server already includes in
169
+ // its `error` field. Result was the same sentence twice in
170
+ // terminal output. The hint must stay short and action-only.
171
+ server.setPublishResponse("alice", "my-skill", {
172
+ status: 403,
173
+ body: {
174
+ error:
175
+ "You do not have permission to publish this skill. Admins, or members with the `canPublish` entitlement, can publish.",
176
+ code: "publish_not_permitted",
177
+ },
178
+ });
179
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
180
+ assert.ok(err instanceof CliError);
181
+ // The server's reason text contains "Admins, or members with
182
+ // the `canPublish` entitlement, can publish." That sentence
183
+ // must NOT appear in the hint too.
184
+ assert.doesNotMatch(
185
+ err.hint ?? "",
186
+ /Admins, or members with the `canPublish` entitlement, can publish\./,
187
+ );
188
+ // But the hint should still be actionable.
189
+ assert.match(err.hint ?? "", /Ask an account admin/);
190
+ return true;
191
+ });
192
+ });
193
+
194
+ it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), billing hint surfaces", async () => {
195
+ // Real-world regression scenario: an account over its skill
196
+ // quota tries to publish. Server returns 403 with code
197
+ // `plan_limit`. The CLI MUST classify this as exit 5
198
+ // validation with the billing hint, NOT exit 2 authError
199
+ // ("contact support") — a pre-review version of the code
200
+ // consumed the 403 body and let mapErrorResponse re-parse, but
201
+ // the body was empty by then so plan_limit fell through to
202
+ // authError.
203
+ server.setPublishResponse("alice", "my-skill", {
204
+ status: 403,
205
+ body: {
206
+ error: "You are over your library skill quota.",
207
+ code: "plan_limit",
208
+ },
209
+ });
210
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
211
+ assert.ok(err instanceof CliError);
212
+ assert.equal(err.exitCode, EXIT_VALIDATION);
213
+ assert.match(err.hint ?? "", /Upgrade your plan|billing/);
214
+ return true;
215
+ });
216
+ });
217
+
218
+ it("422 namespace_unset → CliError EXIT_VALIDATION, reason text passes through", async () => {
219
+ server.setPublishResponse("alice", "my-skill", {
220
+ status: 422,
221
+ body: {
222
+ error: "Set up your account namespace before publishing.",
223
+ code: "namespace_unset",
224
+ },
225
+ });
226
+ await assert.rejects(
227
+ runPublish(baseArgv(), { stdout }),
228
+ (err) =>
229
+ err instanceof CliError &&
230
+ err.exitCode === EXIT_VALIDATION &&
231
+ /account namespace/.test(err.message),
232
+ );
233
+ });
234
+
235
+ it("422 analysis_pending → CliError EXIT_VALIDATION, server reason wins", async () => {
236
+ server.setPublishResponse("alice", "my-skill", {
237
+ status: 422,
238
+ body: {
239
+ error: "This skill must be analyzed before publishing.",
240
+ code: "analysis_pending",
241
+ },
242
+ });
243
+ await assert.rejects(
244
+ runPublish(baseArgv(), { stdout }),
245
+ (err) =>
246
+ err instanceof CliError &&
247
+ err.exitCode === EXIT_VALIDATION &&
248
+ /must be analyzed/.test(err.message),
249
+ );
250
+ });
251
+
252
+ it("422 safety_grade_too_low → CliError EXIT_VALIDATION, server reason wins", async () => {
253
+ server.setPublishResponse("alice", "my-skill", {
254
+ status: 422,
255
+ body: {
256
+ error: "Skills with a safety grade of F cannot be published.",
257
+ code: "safety_grade_too_low",
258
+ },
259
+ });
260
+ await assert.rejects(
261
+ runPublish(baseArgv(), { stdout }),
262
+ (err) =>
263
+ err instanceof CliError &&
264
+ err.exitCode === EXIT_VALIDATION &&
265
+ /safety grade of F cannot be published/.test(err.message),
266
+ );
267
+ });
268
+
269
+ it("401 unauthorized → CliError EXIT_AUTH", async () => {
270
+ server.setPublishResponse("alice", "my-skill", {
271
+ status: 401,
272
+ body: { error: "Invalid access key" },
273
+ });
274
+ await assert.rejects(
275
+ runPublish(baseArgv(), { stdout }),
276
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
277
+ );
278
+ });
279
+ });
280
+
281
+ // ── runUnpublish ────────────────────────────────────────────────────────
282
+
283
+ describe("runUnpublish — happy path", () => {
284
+ beforeEach(setup);
285
+ afterEach(teardown);
286
+
287
+ it("200 unpublished with N=0 subscribers → exit 0, no-notifications message", async () => {
288
+ // Default mock-server response is 200 unpublished, count=0.
289
+ await runUnpublish(baseArgv(), { stdout });
290
+ assert.match(
291
+ stdout.text(),
292
+ /✓ Unpublished @alice\/my-skill — no other accounts had it in their library/,
293
+ );
294
+ });
295
+
296
+ it("200 unpublished with N=1 → singular 'notified 1 subscriber'", async () => {
297
+ server.setUnpublishResponse("alice", "my-skill", {
298
+ status: 200,
299
+ body: {
300
+ action: "unpublished",
301
+ notifiedSubscriberCount: 1,
302
+ skill: { owner: "alice", name: "my-skill" },
303
+ },
304
+ });
305
+ await runUnpublish(baseArgv(), { stdout });
306
+ const out = stdout.text();
307
+ assert.match(out, /notified 1 subscriber\b/);
308
+ assert.doesNotMatch(out, /1 subscribers/);
309
+ });
310
+
311
+ it("200 unpublished with N=5 → plural 'notified 5 subscribers'", async () => {
312
+ server.setUnpublishResponse("alice", "my-skill", {
313
+ status: 200,
314
+ body: {
315
+ action: "unpublished",
316
+ notifiedSubscriberCount: 5,
317
+ skill: { owner: "alice", name: "my-skill" },
318
+ },
319
+ });
320
+ await runUnpublish(baseArgv(), { stdout });
321
+ const out = stdout.text();
322
+ assert.match(out, /notified 5 subscribers/);
323
+ assert.match(out, /they keep their current copy/);
324
+ });
325
+
326
+ it("200 unchanged → exit 0, prints '✓ Already private'", async () => {
327
+ server.setUnpublishResponse("alice", "my-skill", {
328
+ status: 200,
329
+ body: {
330
+ action: "unchanged",
331
+ skill: { owner: "alice", name: "my-skill" },
332
+ },
333
+ });
334
+ await runUnpublish(baseArgv(), { stdout });
335
+ assert.match(stdout.text(), /✓ Already private/);
336
+ });
337
+
338
+ it("--json emits a structured payload with notifiedSubscriberCount (no `ok` field)", async () => {
339
+ server.setUnpublishResponse("alice", "my-skill", {
340
+ status: 200,
341
+ body: {
342
+ action: "unpublished",
343
+ notifiedSubscriberCount: 3,
344
+ skill: { owner: "alice", name: "my-skill" },
345
+ },
346
+ });
347
+ await runUnpublish(baseArgv("--json"), { stdout });
348
+ const parsed = JSON.parse(stdout.text());
349
+ assert.equal(parsed.ok, undefined);
350
+ assert.equal(parsed.action, "unpublished");
351
+ assert.equal(parsed.notifiedSubscriberCount, 3);
352
+ });
353
+
354
+ it("--json on unchanged emits notifiedSubscriberCount=0 (no `ok` field)", async () => {
355
+ server.setUnpublishResponse("alice", "my-skill", {
356
+ status: 200,
357
+ body: {
358
+ action: "unchanged",
359
+ skill: { owner: "alice", name: "my-skill" },
360
+ },
361
+ });
362
+ await runUnpublish(baseArgv("--json"), { stdout });
363
+ const parsed = JSON.parse(stdout.text());
364
+ assert.equal(parsed.ok, undefined);
365
+ assert.equal(parsed.action, "unchanged");
366
+ assert.equal(parsed.notifiedSubscriberCount, 0);
367
+ });
368
+ });
369
+
370
+ describe("runUnpublish — error paths", () => {
371
+ beforeEach(setup);
372
+ afterEach(teardown);
373
+
374
+ it("404 not-found → CliError EXIT_VALIDATION", async () => {
375
+ server.setUnpublishResponse("alice", "my-skill", {
376
+ status: 404,
377
+ body: { error: "Skill not found", code: "skill_not_found" },
378
+ });
379
+ await assert.rejects(
380
+ runUnpublish(baseArgv(), { stdout }),
381
+ (err) =>
382
+ err instanceof CliError &&
383
+ err.exitCode === EXIT_VALIDATION &&
384
+ /not found in your account/.test(err.message),
385
+ );
386
+ });
387
+
388
+ it("403 unpublish_not_permitted → CliError EXIT_SCOPE with action-only hint", async () => {
389
+ server.setUnpublishResponse("alice", "my-skill", {
390
+ status: 403,
391
+ body: {
392
+ error:
393
+ "You do not have permission to unpublish this skill. Admins, or members with the `canPublish` entitlement, can unpublish.",
394
+ code: "unpublish_not_permitted",
395
+ },
396
+ });
397
+ await assert.rejects(runUnpublish(baseArgv(), { stdout }), (err) => {
398
+ assert.ok(err instanceof CliError);
399
+ assert.equal(err.exitCode, EXIT_SCOPE);
400
+ // Same regression guard as the publish path — hint must NOT
401
+ // duplicate the server's entitlement explanation.
402
+ assert.doesNotMatch(
403
+ err.hint ?? "",
404
+ /Admins, or members with the `canPublish` entitlement, can unpublish\./,
405
+ );
406
+ assert.match(err.hint ?? "", /Ask an account admin/);
407
+ return true;
408
+ });
409
+ });
410
+
411
+ it("missing identifier → CliError EXIT_VALIDATION", async () => {
412
+ await assert.rejects(
413
+ runUnpublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
414
+ (err) =>
415
+ err instanceof CliError &&
416
+ err.exitCode === EXIT_VALIDATION &&
417
+ /Missing skill identifier/.test(err.message),
418
+ );
419
+ });
420
+ });
@@ -9,6 +9,8 @@
9
9
  * POST /api/v1/library — multipart file-push (#1452)
10
10
  * POST /api/v1/library/refs — add catalog skill to library (#1451)
11
11
  * DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
12
+ * POST /api/v1/library/[owner]/[name]/publish — make global (#1449)
13
+ * POST /api/v1/library/[owner]/[name]/unpublish — make private (#1449)
12
14
  * GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
13
15
  * GET /api/v1/skills/search — keyword search (PR2)
14
16
  *
@@ -30,6 +32,10 @@
30
32
  * setPushResponse(resp) — response for POST /library multipart push (#1452)
31
33
  * getLastPostBody() — inspect the most recent POST body (JSON-parsed)
32
34
  *
35
+ * (Epic #1444 R3 #1449 additions)
36
+ * setPublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/publish
37
+ * setUnpublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/unpublish
38
+ *
33
39
  * The slot APIs let unit/integration tests configure each scenario
34
40
  * without spinning up multiple servers per test. Default behavior
35
41
  * for an unconfigured slot is a sensible empty payload.
@@ -86,6 +92,10 @@ export function createMockServer(initialPayload, options = {}) {
86
92
  // matches any owner/name when no exact match is registered.
87
93
  let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
88
94
  let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
95
+ // Epic #1444 R3 #1456 — POST /api/v1/library/[owner]/[name]/publish
96
+ // and POST /api/v1/library/[owner]/[name]/unpublish (#1449).
97
+ let publishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
98
+ let unpublishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
89
99
 
90
100
  // #1452 — POST /api/v1/library (multipart file-push) response slot.
91
101
  // Tests set this with `setPushResponse({ status, body })`; when null,
@@ -307,6 +317,86 @@ export function createMockServer(initialPayload, options = {}) {
307
317
  return;
308
318
  }
309
319
 
320
+ // ── #1449: POST /api/v1/library/[owner]/[name]/publish ─────────
321
+ {
322
+ const m = url.pathname.match(
323
+ /^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/publish$/,
324
+ );
325
+ if (m && req.method === "POST") {
326
+ if (!checkAuth(req, res)) return;
327
+ const owner = decodeURIComponent(m[1]);
328
+ const name = decodeURIComponent(m[2]);
329
+ const configured =
330
+ publishResponses.get(`${owner}/${name}`) ?? publishResponses.get("*");
331
+ if (configured) {
332
+ res.writeHead(configured.status, { "Content-Type": "application/json" });
333
+ res.end(JSON.stringify(configured.body));
334
+ return;
335
+ }
336
+ // Default: 200 published with synthesized SyncSkill.
337
+ res.writeHead(200, { "Content-Type": "application/json" });
338
+ res.end(
339
+ JSON.stringify({
340
+ action: "published",
341
+ skill: {
342
+ owner,
343
+ name,
344
+ version: "1.0.0",
345
+ description: "mock",
346
+ keywords: [],
347
+ updatedAt: new Date().toISOString(),
348
+ etag: `"${owner}/${name}@0"`,
349
+ contextSignals: null,
350
+ files: [],
351
+ filesIncomplete: false,
352
+ },
353
+ }),
354
+ );
355
+ return;
356
+ }
357
+ }
358
+
359
+ // ── #1449: POST /api/v1/library/[owner]/[name]/unpublish ───────
360
+ {
361
+ const m = url.pathname.match(
362
+ /^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/unpublish$/,
363
+ );
364
+ if (m && req.method === "POST") {
365
+ if (!checkAuth(req, res)) return;
366
+ const owner = decodeURIComponent(m[1]);
367
+ const name = decodeURIComponent(m[2]);
368
+ const configured =
369
+ unpublishResponses.get(`${owner}/${name}`) ??
370
+ unpublishResponses.get("*");
371
+ if (configured) {
372
+ res.writeHead(configured.status, { "Content-Type": "application/json" });
373
+ res.end(JSON.stringify(configured.body));
374
+ return;
375
+ }
376
+ // Default: 200 unpublished, zero subscribers notified.
377
+ res.writeHead(200, { "Content-Type": "application/json" });
378
+ res.end(
379
+ JSON.stringify({
380
+ action: "unpublished",
381
+ notifiedSubscriberCount: 0,
382
+ skill: {
383
+ owner,
384
+ name,
385
+ version: "1.0.0",
386
+ description: "mock",
387
+ keywords: [],
388
+ updatedAt: new Date().toISOString(),
389
+ etag: `"${owner}/${name}@0"`,
390
+ contextSignals: null,
391
+ files: [],
392
+ filesIncomplete: false,
393
+ },
394
+ }),
395
+ );
396
+ return;
397
+ }
398
+ }
399
+
310
400
  // ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
311
401
  {
312
402
  const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
@@ -563,6 +653,26 @@ export function createMockServer(initialPayload, options = {}) {
563
653
  removeResponses = new Map();
564
654
  },
565
655
 
656
+ // Epic #1444 R3 #1456 — publish / unpublish slot setters.
657
+ setPublishResponse(owner, name, response) {
658
+ publishResponses.set(`${owner}/${name}`, response);
659
+ },
660
+ setPublishResponseForAny(response) {
661
+ publishResponses.set("*", response);
662
+ },
663
+ clearPublishResponses() {
664
+ publishResponses = new Map();
665
+ },
666
+ setUnpublishResponse(owner, name, response) {
667
+ unpublishResponses.set(`${owner}/${name}`, response);
668
+ },
669
+ setUnpublishResponseForAny(response) {
670
+ unpublishResponses.set("*", response);
671
+ },
672
+ clearUnpublishResponses() {
673
+ unpublishResponses = new Map();
674
+ },
675
+
566
676
  /**
567
677
  * #1452 — register the next POST /api/v1/library (multipart file-push)
568
678
  * response. Pass `null` to restore the default 201 LibraryPushResponse.