skillrepo 4.1.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 +22 -1
- package/package.json +10 -4
- package/src/commands/publish.mjs +125 -0
- package/src/commands/push.mjs +187 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/http.mjs +358 -11
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/publish.test.mjs +420 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/mock-server.mjs +202 -10
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
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
|
|
@@ -25,6 +26,9 @@ import { runInit } from "../src/commands/init.mjs";
|
|
|
25
26
|
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";
|
|
29
|
+
import { runPush } from "../src/commands/push.mjs";
|
|
30
|
+
import { runPublish } from "../src/commands/publish.mjs";
|
|
31
|
+
import { runUnpublish } from "../src/commands/unpublish.mjs";
|
|
28
32
|
import { runRemove } from "../src/commands/remove.mjs";
|
|
29
33
|
import { runList } from "../src/commands/list.mjs";
|
|
30
34
|
import { runSearch } from "../src/commands/search.mjs";
|
|
@@ -62,6 +66,23 @@ const COMMANDS = {
|
|
|
62
66
|
usage: "skillrepo add <@owner/name> [--global] [--agent <list>] [--json]",
|
|
63
67
|
run: async (argv) => runAdd(argv),
|
|
64
68
|
},
|
|
69
|
+
push: {
|
|
70
|
+
description: "Push a local skill directory to your library (create or release new version)",
|
|
71
|
+
usage:
|
|
72
|
+
"skillrepo push <path> [--version <label>] [--changelog <text>] " +
|
|
73
|
+
"[--idempotency-key <key>] [--json]",
|
|
74
|
+
run: async (argv) => runPush(argv),
|
|
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
|
+
},
|
|
65
86
|
remove: {
|
|
66
87
|
description: "Remove a skill from your library and delete it locally",
|
|
67
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.
|
|
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": {
|
|
@@ -13,13 +13,19 @@
|
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/skill-repo/skill-repo.git",
|
|
17
17
|
"directory": "packages/cli"
|
|
18
18
|
},
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"skillrepo",
|
|
21
|
+
"cli",
|
|
22
|
+
"mcp",
|
|
23
|
+
"ai-skills"
|
|
24
|
+
],
|
|
20
25
|
"author": "SkillRepo LLC",
|
|
21
26
|
"license": "SEE LICENSE IN LICENSE",
|
|
22
27
|
"dependencies": {
|
|
23
|
-
"cli-table3": "^0.6.5"
|
|
28
|
+
"cli-table3": "^0.6.5",
|
|
29
|
+
"gray-matter": "^4.0.3"
|
|
24
30
|
}
|
|
25
31
|
}
|
|
@@ -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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo push <path>` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Smart upsert: walks a local skill directory and uploads it via
|
|
5
|
+
* `POST /api/v1/library` (#1452 multipart endpoint). The server detects
|
|
6
|
+
* existence by SKILL.md frontmatter name and either creates a new
|
|
7
|
+
* private skill (first push) or releases a new version (subsequent push
|
|
8
|
+
* with changed content). Identical content is a server-side no-op.
|
|
9
|
+
*
|
|
10
|
+
* **No write-back.** The files already live at `<path>` on the user's
|
|
11
|
+
* disk. The CLI uploads them and prints success — it does not write
|
|
12
|
+
* anything back. `skillrepo update` remains the canonical command for
|
|
13
|
+
* disk sync from server → local.
|
|
14
|
+
*
|
|
15
|
+
* Flags: --idempotency-key / --json / --key / --url
|
|
16
|
+
* Positional: <path>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import matter from "gray-matter";
|
|
22
|
+
|
|
23
|
+
import { pushSkill } from "../lib/http.mjs";
|
|
24
|
+
import { walkSkillFiles } from "../lib/skill-walk.mjs";
|
|
25
|
+
import { resolveFlags } from "../lib/cli-config.mjs";
|
|
26
|
+
import { validationError } from "../lib/errors.mjs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `push`. Throws CliError on failure.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} argv
|
|
32
|
+
* @param {object} [io]
|
|
33
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
34
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
35
|
+
*/
|
|
36
|
+
export async function runPush(argv, io = {}) {
|
|
37
|
+
const stdout = io.stdout ?? process.stdout;
|
|
38
|
+
let skillPath = null;
|
|
39
|
+
|
|
40
|
+
// `resolveFlags` only knows the shared flags (`--global`, `--agent`,
|
|
41
|
+
// `--json`, `--key`, `--url`, `--verbose`). The per-command value flag
|
|
42
|
+
// (`--idempotency-key <val>`) is consumed in `acceptPositional` by
|
|
43
|
+
// returning `2` to claim both the flag name and its value.
|
|
44
|
+
let idempotencyKey = null;
|
|
45
|
+
const flags = resolveFlags(argv, {
|
|
46
|
+
acceptPositional(arg, i, allArgv) {
|
|
47
|
+
// Value flags: claim flag + value (2 args).
|
|
48
|
+
if (arg === "--idempotency-key") {
|
|
49
|
+
if (allArgv[i + 1] === undefined) {
|
|
50
|
+
throw validationError("Missing value for --idempotency-key.", {
|
|
51
|
+
hint: "Pass a key, e.g., --idempotency-key my-uuid-here.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
idempotencyKey = allArgv[i + 1];
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
// Otherwise it must be the (sole) positional skill-path argument.
|
|
58
|
+
if (skillPath !== null) {
|
|
59
|
+
throw validationError(`Unexpected extra argument: ${arg}`, {
|
|
60
|
+
hint: "Pass exactly one local directory path.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
skillPath = arg;
|
|
64
|
+
return 1;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!skillPath) {
|
|
69
|
+
throw validationError("Missing skill directory path.", {
|
|
70
|
+
hint: "Usage: skillrepo push <path-to-skill>",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Resolve the skill directory ────────────────────────────────────
|
|
75
|
+
const absDir = path.resolve(process.cwd(), skillPath);
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = await fs.stat(absDir);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw validationError(
|
|
81
|
+
`Path not found: ${skillPath}`,
|
|
82
|
+
{ hint: `Resolved to ${absDir}. Pass a directory containing a SKILL.md.`, cause: err },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isDirectory()) {
|
|
86
|
+
throw validationError(`Not a directory: ${skillPath}`, {
|
|
87
|
+
hint: "Pass a directory containing a SKILL.md, not a single file.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Read + parse SKILL.md (local validation only) ──────────────────
|
|
92
|
+
// The walker below picks SKILL.md up as a regular file and sends it
|
|
93
|
+
// through the multipart `files[]` array. We still read it here for an
|
|
94
|
+
// early failure check: without this the user would upload every
|
|
95
|
+
// supporting file before the server's frontmatter-parser rejected the
|
|
96
|
+
// push.
|
|
97
|
+
const skillMdPath = path.join(absDir, "SKILL.md");
|
|
98
|
+
let skillMdLocal;
|
|
99
|
+
try {
|
|
100
|
+
skillMdLocal = await fs.readFile(skillMdPath, "utf-8");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw validationError(`No SKILL.md at ${skillPath}/SKILL.md.`, {
|
|
103
|
+
hint:
|
|
104
|
+
"Every skill must have a SKILL.md at its root with YAML " +
|
|
105
|
+
"frontmatter including `name` and `description` fields.",
|
|
106
|
+
cause: err,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let frontmatter;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = matter(skillMdLocal);
|
|
113
|
+
frontmatter = parsed.data;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw validationError(
|
|
116
|
+
`SKILL.md frontmatter could not be parsed.`,
|
|
117
|
+
{
|
|
118
|
+
hint:
|
|
119
|
+
"Ensure the file starts with `---`, contains valid YAML, and " +
|
|
120
|
+
"ends the frontmatter block with `---` on its own line.",
|
|
121
|
+
cause: err,
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!frontmatter?.name || typeof frontmatter.name !== "string") {
|
|
127
|
+
throw validationError("SKILL.md is missing the required `name` field.", {
|
|
128
|
+
hint: "Add `name: my-skill-name` to the YAML frontmatter.",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Walk the skill folder ──────────────────────────────────────────
|
|
133
|
+
// The walker returns every file (including the root `SKILL.md`) per
|
|
134
|
+
// the agentskills.io spec. They all go up as `files[]` parts.
|
|
135
|
+
const walked = await walkSkillFiles(absDir);
|
|
136
|
+
|
|
137
|
+
const files = await Promise.all(
|
|
138
|
+
walked.map(async (f) => ({
|
|
139
|
+
relativePath: f.relativePath,
|
|
140
|
+
content: await fs.readFile(f.absolutePath),
|
|
141
|
+
})),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── POST to /api/v1/library ────────────────────────────────────────
|
|
145
|
+
const result = await pushSkill(flags.serverUrl, flags.apiKey, {
|
|
146
|
+
files,
|
|
147
|
+
idempotencyKey: idempotencyKey ?? undefined,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Report ─────────────────────────────────────────────────────────
|
|
151
|
+
const totalUploaded = files.length;
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
stdout.write(
|
|
154
|
+
JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
action: result.action,
|
|
157
|
+
bump: result.bump,
|
|
158
|
+
owner: result.skill?.owner ?? null,
|
|
159
|
+
name: result.skill?.name ?? frontmatter.name,
|
|
160
|
+
version: result.skill?.version ?? null,
|
|
161
|
+
filesUploaded: totalUploaded,
|
|
162
|
+
},
|
|
163
|
+
null,
|
|
164
|
+
2,
|
|
165
|
+
) + "\n",
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ident = `@${result.skill?.owner ?? "?"}/${result.skill?.name ?? frontmatter.name}`;
|
|
171
|
+
const fileCount = `${totalUploaded} file${totalUploaded === 1 ? "" : "s"}`;
|
|
172
|
+
|
|
173
|
+
if (result.action === "created") {
|
|
174
|
+
stdout.write(
|
|
175
|
+
`\n ✓ Created ${ident} v${result.skill?.version ?? "1.0"} (${fileCount})\n\n`,
|
|
176
|
+
);
|
|
177
|
+
} else if (result.action === "updated") {
|
|
178
|
+
stdout.write(
|
|
179
|
+
`\n ✓ Released ${ident} v${result.skill?.version} (${result.bump} bump, ${fileCount})\n\n`,
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
// unchanged
|
|
183
|
+
stdout.write(
|
|
184
|
+
`\n ✓ No changes — ${ident} is already at v${result.skill?.version}\n\n`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -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
|
+
}
|