skills 1.5.26 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/dist/cli.mjs +440 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,13 @@ The CLI for the open agent skills ecosystem.
6
6
  Supports **OpenCode**, **Claude Code**, **Codex**, **Cursor**, and [75 more](#supported-agents).
7
7
  <!-- agent-list:end -->
8
8
 
9
- [![skills.sh](https://skills.sh/b/vercel-labs/skills)](https://skills.sh/vercel-labs/skills)
9
+ <p>
10
+ <a href="https://vercel.com/labs#labs-products"><img alt="Vercel Labs Product" src="https://img.shields.io/badge/LABS-PRODUCT-0a0a0a.svg?style=for-the-badge&amp;logo=Vercel&amp;labelColor=000000" height="28"></a>
11
+ <a href="https://www.npmjs.com/package/skills"><img alt="npm version: skills" src="https://img.shields.io/npm/v/skills.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
12
+ <a href="https://github.com/vercel-labs/skills/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/github/license/vercel-labs/skills.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
13
+ <a href="https://www.npmjs.com/package/skills"><img alt="npm downloads per month: skills" src="https://img.shields.io/npm/dm/skills.svg?style=for-the-badge&amp;labelColor=000000&amp;label=npm%20downloads" height="28"></a>
14
+ <a href="https://skills.sh/vercel-labs/skills"><img alt="skills.sh" src="https://skills.sh/b/vercel-labs/skills?style=for-the-badge" height="28"></a>
15
+ </p>
10
16
 
11
17
  ## Install a Skill
12
18
 
@@ -40,6 +46,10 @@ npx skills add https://github.com/vercel-labs/agent-skills/tree/main/skills/web-
40
46
  # GitLab URL
41
47
  npx skills add https://gitlab.com/org/repo
42
48
 
49
+ # Azure Repos (Azure DevOps Services or Server)
50
+ npx skills add https://dev.azure.com/org/project/_git/repo
51
+ npx skills add https://dev.azure.com/org/project/_git/repo?path=/skills/web-design&version=GBmain
52
+
43
53
  # Any git URL
44
54
  npx skills add git@github.com:vercel-labs/agent-skills.git
45
55
 
@@ -61,6 +71,7 @@ npx skills add ssh://git@git.example.com/acme/private-skills.git
61
71
 
62
72
  # HTTPS on any Git host (uses your configured Git credential helper)
63
73
  npx skills add https://git.example.com/acme/private-skills.git
74
+ npx skills add https://dev.azure.com/org/project/_git/private-skills
64
75
  ```
65
76
 
66
77
  For GitHub HTTPS and shorthand sources, `skills` first uses normal Git credentials. If that fails and GitHub CLI is authenticated, it tries `gh repo clone`, followed by SSH. It does not execute `gh auth token` or copy the stored GitHub CLI credential into the Node.js process.
package/dist/cli.mjs CHANGED
@@ -11,7 +11,7 @@ import { basename, dirname, extname, isAbsolute, join, normalize, relative, reso
11
11
  import { fileURLToPath } from "url";
12
12
  import { stripVTControlCharacters } from "node:util";
13
13
  import { createWriteStream } from "node:fs";
14
- import { dirname as dirname$1, join as join$1, normalize as normalize$1, resolve as resolve$1, sep as sep$1 } from "node:path";
14
+ import { dirname as dirname$1, join as join$1, normalize as normalize$1, relative as relative$1, resolve as resolve$1, sep as sep$1 } from "node:path";
15
15
  import { homedir, platform, tmpdir } from "os";
16
16
  import * as readline from "readline";
17
17
  import { Writable } from "stream";
@@ -20,13 +20,13 @@ import { execFile, spawn, spawnSync } from "child_process";
20
20
  import { access, chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat, symlink, writeFile } from "fs/promises";
21
21
  import { parse, stringify } from "yaml";
22
22
  import { createHash } from "crypto";
23
- import { mkdir as mkdir$1, mkdtemp as mkdtemp$1, readFile as readFile$1, rm as rm$1, stat as stat$1, writeFile as writeFile$1 } from "node:fs/promises";
23
+ import { cp as cp$1, mkdir as mkdir$1, mkdtemp as mkdtemp$1, readFile as readFile$1, rm as rm$1, stat as stat$1, writeFile as writeFile$1 } from "node:fs/promises";
24
24
  import { createHash as createHash$1 } from "node:crypto";
25
25
  import { crc32, gunzipSync, inflateRawSync } from "node:zlib";
26
26
  import { tmpdir as tmpdir$1 } from "node:os";
27
27
  import { pipeline } from "node:stream/promises";
28
28
  import * as tar from "tar";
29
- import { execFile as execFile$1 } from "node:child_process";
29
+ import { execFile as execFile$1, spawn as spawn$1 } from "node:child_process";
30
30
  var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
31
31
  const DEFAULT_GITHUB_HOST = "github.com";
32
32
  function getGitHubHost() {
@@ -91,6 +91,46 @@ function sanitizeSubpath(subpath) {
91
91
  for (const segment of segments) if (segment === "..") throw new Error(`Unsafe subpath: "${subpath}" contains path traversal segments. Subpaths must not contain ".." components.`);
92
92
  return subpath;
93
93
  }
94
+ function azureReposVersionToRef(version) {
95
+ if (!version) return;
96
+ return /^(?:GB|GT)(.+)$/i.exec(version)?.[1] || void 0;
97
+ }
98
+ function parseAzureReposSource(input, fragmentRef, fragmentSkillFilter) {
99
+ if (!input.startsWith("http://") && !input.startsWith("https://")) return null;
100
+ let parsed;
101
+ try {
102
+ parsed = new URL(input);
103
+ } catch {
104
+ return null;
105
+ }
106
+ const segments = parsed.pathname.split("/").filter(Boolean);
107
+ const gitIndex = segments.indexOf("_git");
108
+ if (gitIndex < 0 || gitIndex === segments.length - 1) return null;
109
+ const repo = segments[gitIndex + 1].replace(/\.git$/, "");
110
+ if (!repo) return null;
111
+ const clonePath = "/" + [
112
+ ...segments.slice(0, gitIndex),
113
+ "_git",
114
+ repo
115
+ ].map((segment) => {
116
+ try {
117
+ return encodeURIComponent(decodeURIComponent(segment));
118
+ } catch {
119
+ return encodeURIComponent(segment);
120
+ }
121
+ }).join("/");
122
+ const cloneUrl = `${parsed.protocol}//${parsed.host}${clonePath}`;
123
+ const ref = azureReposVersionToRef(parsed.searchParams.get("version")) || fragmentRef;
124
+ const pathParam = parsed.searchParams.get("path");
125
+ const subpath = pathParam ? sanitizeSubpath(pathParam.replace(/^\/+/, "")) : void 0;
126
+ return {
127
+ type: "git",
128
+ url: cloneUrl,
129
+ ...ref ? { ref } : {},
130
+ ...subpath ? { subpath } : {},
131
+ ...fragmentSkillFilter ? { skillFilter: fragmentSkillFilter } : {}
132
+ };
133
+ }
94
134
  function isLocalPath(input) {
95
135
  return isAbsolute(input) || input.startsWith("./") || input.startsWith("../") || input === "." || input === ".." || /^[a-zA-Z]:[/\\]/.test(input);
96
136
  }
@@ -113,6 +153,9 @@ function looksLikeGitSource(input) {
113
153
  const pathname = parsed.pathname;
114
154
  if (isGitHubHost(parsed.host)) return /^\/[^/]+\/[^/]+(?:\.git)?(?:\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname);
115
155
  if (parsed.hostname === "gitlab.com") return /^\/.+?\/[^/]+(?:\.git)?(?:\/-\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname);
156
+ const azureSegments = pathname.split("/").filter(Boolean);
157
+ const azureGitIndex = azureSegments.indexOf("_git");
158
+ if (azureGitIndex >= 0 && azureGitIndex < azureSegments.length - 1) return true;
116
159
  } catch {}
117
160
  if (/^https?:\/\/.+\.git(?:$|[/?])/i.test(input)) return true;
118
161
  return !input.includes(":") && !input.startsWith(".") && !input.startsWith("/") && /^([^/]+)\/([^/]+)(?:\/(.+)|@(.+))?$/.test(input);
@@ -245,6 +288,8 @@ function parseSource(input) {
245
288
  ...fragmentRef ? { ref: fragmentRef } : {}
246
289
  };
247
290
  }
291
+ const azureRepos = parseAzureReposSource(input, fragmentRef, fragmentSkillFilter);
292
+ if (azureRepos) return azureRepos;
248
293
  const githubHost = getGitHubHost();
249
294
  const shorthandSourceType = githubHost === "github.com" ? "github" : "git";
250
295
  const atSkillMatch = input.match(/^([^/]+)\/([^/@]+)@(.+)$/);
@@ -1183,7 +1228,7 @@ function normalizeSkillName$1(name) {
1183
1228
  function normalizeRelativePath(path) {
1184
1229
  return path.split(sep).join("/").replace(/\/+/g, "/");
1185
1230
  }
1186
- function isRecord(value) {
1231
+ function isRecord$1(value) {
1187
1232
  return typeof value === "object" && value !== null && !Array.isArray(value);
1188
1233
  }
1189
1234
  function shouldInstallInternalSkills() {
@@ -1226,7 +1271,7 @@ async function parseSkillMd(skillMdPath, options) {
1226
1271
  warnSkippedSkill(skillMdPath, `frontmatter "name" and "description" must be strings (got ${typeof data.name} and ${typeof data.description})`);
1227
1272
  return null;
1228
1273
  }
1229
- const metadata = isRecord(data.metadata) ? data.metadata : void 0;
1274
+ const metadata = isRecord$1(data.metadata) ? data.metadata : void 0;
1230
1275
  if (metadata?.internal === true && !shouldInstallInternalSkills() && !options?.includeInternal) return null;
1231
1276
  return {
1232
1277
  name: sanitizeMetadata(data.name),
@@ -3515,17 +3560,20 @@ const DEFAULT_DOWNLOAD_MAX_BYTES = 10 * 1024 * 1024;
3515
3560
  const DEFAULT_EXTRACT_MAX_BYTES = 25 * 1024 * 1024;
3516
3561
  const DEFAULT_EXTRACT_MAX_FILES = 1e3;
3517
3562
  const FETCH_TIMEOUT_MS = 3e4;
3563
+ function positiveIntegerOr(value, fallback) {
3564
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
3565
+ }
3518
3566
  function getPositiveIntegerEnv(name, fallback) {
3519
3567
  const raw = process.env[name];
3520
3568
  if (!raw) return fallback;
3521
3569
  const parsed = Number.parseInt(raw, 10);
3522
3570
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3523
3571
  }
3524
- function getDownloadLimits() {
3572
+ function getDownloadLimits(options) {
3525
3573
  return {
3526
- downloadMaxBytes: getPositiveIntegerEnv("SKILLS_DOWNLOAD_MAX_BYTES", DEFAULT_DOWNLOAD_MAX_BYTES),
3527
- extractMaxBytes: getPositiveIntegerEnv("SKILLS_EXTRACT_MAX_BYTES", DEFAULT_EXTRACT_MAX_BYTES),
3528
- extractMaxFiles: getPositiveIntegerEnv("SKILLS_EXTRACT_MAX_FILES", DEFAULT_EXTRACT_MAX_FILES)
3574
+ downloadMaxBytes: getPositiveIntegerEnv("SKILLS_DOWNLOAD_MAX_BYTES", positiveIntegerOr(options.downloadMaxBytes, DEFAULT_DOWNLOAD_MAX_BYTES)),
3575
+ extractMaxBytes: getPositiveIntegerEnv("SKILLS_EXTRACT_MAX_BYTES", positiveIntegerOr(options.extractMaxBytes, DEFAULT_EXTRACT_MAX_BYTES)),
3576
+ extractMaxFiles: getPositiveIntegerEnv("SKILLS_EXTRACT_MAX_FILES", positiveIntegerOr(options.extractMaxFiles, DEFAULT_EXTRACT_MAX_FILES))
3529
3577
  };
3530
3578
  }
3531
3579
  function isPathSafe$1(basePath, targetPath) {
@@ -3659,8 +3707,8 @@ async function getSingleTopLevelDirectory(dir) {
3659
3707
  if (visibleEntries.length !== 1 || !visibleEntries[0].isDirectory()) return null;
3660
3708
  return join$1(dir, visibleEntries[0].name);
3661
3709
  }
3662
- async function downloadSource(url) {
3663
- const limits = getDownloadLimits();
3710
+ async function downloadSource(url, options = {}) {
3711
+ const limits = getDownloadLimits(options);
3664
3712
  const tempDir = await mkdtemp$1(join$1(tmpdir$1(), "skills-download-"));
3665
3713
  const downloadedFile = join$1(tempDir, "source.download");
3666
3714
  const extractDir = join$1(tempDir, "extract");
@@ -4124,7 +4172,348 @@ async function tryBlobInstall(ownerRepo, options = {}) {
4124
4172
  tree
4125
4173
  };
4126
4174
  }
4127
- var version$1 = "1.5.26";
4175
+ var version$1 = "1.7.0";
4176
+ const NOTION_API_VERSION = "2026-03-11";
4177
+ const NTN_TIMEOUT_MS = 3e4;
4178
+ const NTN_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
4179
+ const NOTION_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024;
4180
+ const NOTION_EXTRACT_MAX_BYTES = 100 * 1024 * 1024;
4181
+ const NOTION_EXTRACT_MAX_FILES = 5e3;
4182
+ function isRecord(value) {
4183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4184
+ }
4185
+ function assertString(value, field) {
4186
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Notion Agent Plugins response is missing ${field}`);
4187
+ return value;
4188
+ }
4189
+ function optionalMetadata(value, field) {
4190
+ if (value === void 0 || value === null) return "";
4191
+ if (typeof value !== "string") throw new Error(`Notion Agent Plugins response has an invalid ${field}`);
4192
+ return sanitizeMetadata(value);
4193
+ }
4194
+ function parsePack(value) {
4195
+ if (!isRecord(value)) throw new Error("Notion Agent Plugins response contains an invalid pack");
4196
+ if (typeof value.name !== "string") throw new Error("Notion Agent Plugins response is missing pack.name");
4197
+ const name = sanitizeMetadata(value.name);
4198
+ if (!name) return null;
4199
+ return {
4200
+ id: assertString(value.id, "pack.id"),
4201
+ name,
4202
+ description: optionalMetadata(value.description, "pack.description"),
4203
+ version_id: assertString(value.version_id, "pack.version_id")
4204
+ };
4205
+ }
4206
+ function parsePackList(value) {
4207
+ if (!isRecord(value) || !Array.isArray(value.results)) throw new Error("Notion Agent Plugins list response is invalid");
4208
+ if (typeof value.has_more !== "boolean") throw new Error("Notion Agent Plugins list response is missing has_more");
4209
+ const nextCursor = value.next_cursor;
4210
+ if (nextCursor !== null && typeof nextCursor !== "string") throw new Error("Notion Agent Plugins list response has an invalid next_cursor");
4211
+ return {
4212
+ results: value.results.map(parsePack).filter((pack) => pack !== null),
4213
+ next_cursor: nextCursor,
4214
+ has_more: value.has_more
4215
+ };
4216
+ }
4217
+ function parseDirectory(value, label) {
4218
+ if (!isRecord(value)) throw new Error(`Notion ${label} directory response is invalid`);
4219
+ return {
4220
+ id: assertString(value.id, `${label} directory id`),
4221
+ version_id: assertString(value.version_id, `${label} directory version_id`),
4222
+ url: assertString(value.url, `${label} directory url`)
4223
+ };
4224
+ }
4225
+ function downloadNotionDirectory(url) {
4226
+ return downloadSource(url, {
4227
+ downloadMaxBytes: NOTION_DOWNLOAD_MAX_BYTES,
4228
+ extractMaxBytes: NOTION_EXTRACT_MAX_BYTES,
4229
+ extractMaxFiles: NOTION_EXTRACT_MAX_FILES
4230
+ });
4231
+ }
4232
+ function debugNtn(args) {
4233
+ if (process.env.SKILLS_DEBUG === "1") console.error(`[notion] ntn ${args.join(" ")}`);
4234
+ }
4235
+ async function runNtnApi(args) {
4236
+ debugNtn(args);
4237
+ return new Promise((resolve, reject) => {
4238
+ const child = spawn$1("ntn", args, {
4239
+ env: process.env,
4240
+ stdio: [
4241
+ "ignore",
4242
+ "pipe",
4243
+ "pipe"
4244
+ ]
4245
+ });
4246
+ const stdoutChunks = [];
4247
+ const stderrChunks = [];
4248
+ let outputBytes = 0;
4249
+ let settled = false;
4250
+ const finish = (callback) => {
4251
+ if (settled) return;
4252
+ settled = true;
4253
+ clearTimeout(timeout);
4254
+ callback();
4255
+ };
4256
+ const fail = (message) => finish(() => reject(new Error(message)));
4257
+ const append = (chunks, chunk) => {
4258
+ outputBytes += chunk.length;
4259
+ if (outputBytes > NTN_MAX_BUFFER_BYTES) {
4260
+ child.kill("SIGTERM");
4261
+ fail("ntn api output exceeded 10 MiB");
4262
+ return;
4263
+ }
4264
+ chunks.push(chunk);
4265
+ };
4266
+ const timeout = setTimeout(() => {
4267
+ child.kill("SIGTERM");
4268
+ fail(`ntn api timed out after ${NTN_TIMEOUT_MS / 1e3} seconds`);
4269
+ }, NTN_TIMEOUT_MS);
4270
+ child.stdout.on("data", (chunk) => append(stdoutChunks, chunk));
4271
+ child.stderr.on("data", (chunk) => append(stderrChunks, chunk));
4272
+ child.on("error", (error) => {
4273
+ if (error.code === "ENOENT") {
4274
+ fail("Notion CLI (ntn) is required. Install it from:\nhttps://developers.notion.com/cli/get-started/overview\nThen run `ntn login`.");
4275
+ return;
4276
+ }
4277
+ fail(`Unable to start ntn: ${stripTerminalEscapes(error.message)}`);
4278
+ });
4279
+ child.on("close", (code) => {
4280
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
4281
+ const stderr = stripTerminalEscapes(Buffer.concat(stderrChunks).toString("utf8")).trim();
4282
+ if (code === 0) {
4283
+ finish(() => resolve(stdout));
4284
+ return;
4285
+ }
4286
+ fail(`ntn api failed${stderr ? `: ${stderr}` : ` with exit code ${code ?? "unknown"}`}`);
4287
+ });
4288
+ });
4289
+ }
4290
+ async function fetchNotionJson(args, label, runNtn = runNtnApi) {
4291
+ const output = await runNtn([
4292
+ "api",
4293
+ ...args,
4294
+ "--notion-version",
4295
+ NOTION_API_VERSION
4296
+ ]);
4297
+ try {
4298
+ return JSON.parse(output);
4299
+ } catch (error) {
4300
+ if (error instanceof SyntaxError) throw new Error(`ntn returned invalid JSON for ${label}`);
4301
+ throw error;
4302
+ }
4303
+ }
4304
+ async function fetchNotionPacks(options = {}) {
4305
+ const runNtn = options.runNtn ?? runNtnApi;
4306
+ const packs = [];
4307
+ const seenCursors = /* @__PURE__ */ new Set();
4308
+ let cursor = null;
4309
+ do {
4310
+ const args = ["/v1/ai/plugins", "page_size==100"];
4311
+ if (cursor) args.push(`start_cursor==${cursor}`);
4312
+ const page = parsePackList(await fetchNotionJson(args, "the Notion packs list", runNtn));
4313
+ packs.push(...page.results);
4314
+ if (!page.has_more) break;
4315
+ if (!page.next_cursor || seenCursors.has(page.next_cursor)) throw new Error("Notion Agent Plugins pagination returned an invalid cursor");
4316
+ cursor = page.next_cursor;
4317
+ seenCursors.add(cursor);
4318
+ } while (cursor);
4319
+ return packs;
4320
+ }
4321
+ async function fetchNotionPackDirectory(pack, options = {}) {
4322
+ const directory = parseDirectory(await fetchNotionJson([`/v1/ai/plugins/${encodeURIComponent(pack.id)}`], `Notion pack ${JSON.stringify(pack.name)}`, options.runNtn), "pack");
4323
+ if (directory.id !== pack.id || directory.version_id !== pack.version_id) throw new Error(`Notion pack ${JSON.stringify(pack.name)} changed while preparing installation`);
4324
+ return directory;
4325
+ }
4326
+ async function fetchNotionWorkspaceName(options = {}) {
4327
+ const runNtn = options.runNtn ?? runNtnApi;
4328
+ try {
4329
+ const value = JSON.parse(await runNtn([
4330
+ "api",
4331
+ "/v1/users/me",
4332
+ "--notion-version",
4333
+ NOTION_API_VERSION
4334
+ ]));
4335
+ if (!isRecord(value) || !isRecord(value.bot)) return null;
4336
+ const name = value.bot.workspace_name;
4337
+ return typeof name === "string" && name.length > 0 ? sanitizeMetadata(name) : null;
4338
+ } catch {
4339
+ return null;
4340
+ }
4341
+ }
4342
+ function inWorkspace(workspace) {
4343
+ return workspace ? ` in workspace ${import_picocolors.default.cyan(workspace)}` : "";
4344
+ }
4345
+ function isNotionSource(source) {
4346
+ return source.toLowerCase() === "notion";
4347
+ }
4348
+ const NOTION_HOSTNAME = /(^|\.)notion\.(so|com)$/;
4349
+ const NOTION_PAGE_ID = /(?:^|-)([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
4350
+ function formatPageId(rawId) {
4351
+ const hex = rawId.replace(/-/g, "");
4352
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
4353
+ }
4354
+ function parseNotionSkillUrl(source) {
4355
+ let url;
4356
+ try {
4357
+ url = new URL(source);
4358
+ } catch {
4359
+ return null;
4360
+ }
4361
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
4362
+ if (!NOTION_HOSTNAME.test(url.hostname.toLowerCase())) return null;
4363
+ const lastSegment = url.pathname.split("/").filter(Boolean).pop();
4364
+ if (!lastSegment) return null;
4365
+ let decoded;
4366
+ try {
4367
+ decoded = decodeURIComponent(lastSegment);
4368
+ } catch {
4369
+ decoded = lastSegment;
4370
+ }
4371
+ const match = decoded.toLowerCase().match(NOTION_PAGE_ID);
4372
+ return match ? formatPageId(match[1]) : null;
4373
+ }
4374
+ async function fetchNotionSkillDirectory(pageId, options = {}) {
4375
+ return parseDirectory(await fetchNotionJson([`/v1/ai/skills/${encodeURIComponent(pageId)}`], `Notion skill ${pageId}`, options.runNtn), "skill");
4376
+ }
4377
+ async function prepareNotionSkillSource(pageId, options = {}) {
4378
+ const download = options.download ?? downloadNotionDirectory;
4379
+ const spinner$4 = spinner();
4380
+ spinner$4.start("Fetching Notion skill with ntn…");
4381
+ try {
4382
+ const [directory, workspace] = await Promise.all([fetchNotionSkillDirectory(pageId, { runNtn: options.runNtn }), fetchNotionWorkspaceName({ runNtn: options.runNtn })]);
4383
+ const downloaded = await download(directory.url);
4384
+ spinner$4.stop(`Downloaded skill from Notion${inWorkspace(workspace)}`);
4385
+ return {
4386
+ rootDir: downloaded.rootDir,
4387
+ tempDir: downloaded.tempDir
4388
+ };
4389
+ } catch (error) {
4390
+ spinner$4.stop(import_picocolors.default.red("Failed to prepare Notion skill"));
4391
+ throw error;
4392
+ }
4393
+ }
4394
+ function normalizeSelector(value) {
4395
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4396
+ }
4397
+ function filterPacks(packs, selectors) {
4398
+ if (selectors.includes("*")) return packs;
4399
+ const normalized = new Set(selectors.map(normalizeSelector));
4400
+ return packs.filter((pack) => normalized.has(normalizeSelector(pack.name)) || normalized.has(pack.id.toLowerCase()));
4401
+ }
4402
+ function sortedPacks(packs) {
4403
+ return [...packs].sort((a, b) => a.name.localeCompare(b.name));
4404
+ }
4405
+ function printAvailablePacks(packs) {
4406
+ console.log();
4407
+ log.step(import_picocolors.default.bold("Available Notion packs"));
4408
+ for (const pack of sortedPacks(packs)) {
4409
+ log.message(import_picocolors.default.cyan(pack.name));
4410
+ if (pack.description) log.message(` ${import_picocolors.default.dim(pack.description)}`);
4411
+ }
4412
+ }
4413
+ async function prepareNotionPackSource(options = {}) {
4414
+ const spinner$5 = spinner();
4415
+ spinner$5.start("Fetching Notion packs with ntn…");
4416
+ let packs;
4417
+ let workspace = null;
4418
+ try {
4419
+ [packs, workspace] = await Promise.all([fetchNotionPacks({ runNtn: options.runNtn }), fetchNotionWorkspaceName({ runNtn: options.runNtn })]);
4420
+ } catch (error) {
4421
+ spinner$5.stop(import_picocolors.default.red("Failed to load Notion packs"));
4422
+ throw error;
4423
+ }
4424
+ spinner$5.stop(`Found ${import_picocolors.default.green(packs.length)} Notion pack${packs.length === 1 ? "" : "s"}${inWorkspace(workspace)}`);
4425
+ if (packs.length === 0) throw new Error("Notion returned no packs for the authenticated workspace");
4426
+ if (options.list) {
4427
+ printAvailablePacks(packs);
4428
+ outro(import_picocolors.default.dim("Select a pack by running the command without --list."));
4429
+ return null;
4430
+ }
4431
+ let selectedPacks;
4432
+ if (options.skill && options.skill.length > 0) {
4433
+ selectedPacks = filterPacks(packs, options.skill);
4434
+ if (selectedPacks.length === 0) throw new Error(`No matching Notion packs found for: ${options.skill.join(", ")}`);
4435
+ } else if (options.yes || !process.stdin.isTTY) selectedPacks = packs;
4436
+ else {
4437
+ const selected = await searchMultiselect({
4438
+ message: `Select Notion packs to install ${import_picocolors.default.dim("(space to toggle)")}`,
4439
+ items: sortedPacks(packs).map((pack) => ({
4440
+ value: pack,
4441
+ label: pack.name,
4442
+ detail: pack.description || "Notion plugin pack"
4443
+ })),
4444
+ required: true,
4445
+ maxVisible: 20,
4446
+ searchable: false,
4447
+ showDetail: true,
4448
+ showSelectedSummary: false
4449
+ });
4450
+ if (typeof selected === "symbol") {
4451
+ cancel("Selection cancelled");
4452
+ return null;
4453
+ }
4454
+ selectedPacks = selected;
4455
+ }
4456
+ note(selectedPacks.map((pack) => import_picocolors.default.cyan(pack.name)).join("\n"), `Selected ${selectedPacks.length} Notion pack${selectedPacks.length === 1 ? "" : "s"}`);
4457
+ const stagingDir = await mkdtemp$1(join$1(tmpdir$1(), "skills-notion-"));
4458
+ const packsDir = join$1(stagingDir, "packs");
4459
+ const manifestPlugins = [];
4460
+ const download = options.download ?? downloadNotionDirectory;
4461
+ const discover = options.discover ?? discoverSkills;
4462
+ let skillCount = 0;
4463
+ spinner$5.start("Preparing selected Notion packs…");
4464
+ try {
4465
+ await mkdir$1(packsDir, { recursive: true });
4466
+ for (const [index, pack] of selectedPacks.entries()) {
4467
+ let downloaded = null;
4468
+ try {
4469
+ downloaded = await download((await fetchNotionPackDirectory(pack, { runNtn: options.runNtn })).url);
4470
+ const skills = await discover(downloaded.rootDir, void 0, {
4471
+ includeInternal: true,
4472
+ fullDepth: true
4473
+ });
4474
+ if (skills.length === 0) throw new Error("pack contains no valid skills");
4475
+ const directoryName = `pack-${index + 1}-${sanitizeName(pack.name).slice(0, 100)}`;
4476
+ const targetDir = join$1(packsDir, directoryName);
4477
+ await cp$1(downloaded.rootDir, targetDir, {
4478
+ recursive: true,
4479
+ force: false,
4480
+ errorOnExist: true
4481
+ });
4482
+ manifestPlugins.push({
4483
+ name: pack.name,
4484
+ source: `./${directoryName}`,
4485
+ skills: skills.map((skill) => {
4486
+ const skillPath = relative$1(downloaded.rootDir, skill.path).split(sep$1).join("/");
4487
+ return skillPath ? `./${skillPath}` : "./.";
4488
+ })
4489
+ });
4490
+ skillCount += skills.length;
4491
+ } catch (error) {
4492
+ const detail = error instanceof Error ? error.message : "Unknown error";
4493
+ throw new Error(`Failed to prepare Notion pack ${JSON.stringify(pack.name)}: ${detail}`);
4494
+ } finally {
4495
+ if (downloaded) await cleanupTempDir(downloaded.tempDir).catch(() => {});
4496
+ }
4497
+ }
4498
+ const manifestDir = join$1(stagingDir, ".claude-plugin");
4499
+ await mkdir$1(manifestDir, { recursive: true });
4500
+ await writeFile$1(join$1(manifestDir, "marketplace.json"), `${JSON.stringify({
4501
+ metadata: { pluginRoot: "./packs" },
4502
+ plugins: manifestPlugins
4503
+ }, null, 2)}\n`, "utf8");
4504
+ spinner$5.stop(`Prepared ${import_picocolors.default.green(selectedPacks.length)} Notion pack${selectedPacks.length === 1 ? "" : "s"} with ${import_picocolors.default.green(skillCount)} skill${skillCount === 1 ? "" : "s"}`);
4505
+ return {
4506
+ rootDir: stagingDir,
4507
+ tempDir: stagingDir,
4508
+ packCount: selectedPacks.length,
4509
+ skillCount
4510
+ };
4511
+ } catch (error) {
4512
+ spinner$5.stop(import_picocolors.default.red("Failed to prepare Notion packs"));
4513
+ await cleanupTempDir(stagingDir).catch(() => {});
4514
+ throw error;
4515
+ }
4516
+ }
4128
4517
  const isCancelled$1 = (value) => typeof value === "symbol";
4129
4518
  const EVE_AGENT_LABEL = "eve agent";
4130
4519
  async function isSourcePrivate(source) {
@@ -4365,6 +4754,15 @@ function isSkillsShPackUrl(url) {
4365
4754
  return false;
4366
4755
  }
4367
4756
  }
4757
+ function logAutoSelectedSkills(entries) {
4758
+ const only = entries.length === 1 ? entries[0] : null;
4759
+ if (!only) {
4760
+ log.info(`Installing all ${entries.length} skills`);
4761
+ return;
4762
+ }
4763
+ log.info(`Skill: ${import_picocolors.default.cyan(only.label)}`);
4764
+ if (only.description) log.message(import_picocolors.default.dim(only.description));
4765
+ }
4368
4766
  async function handleWellKnownSkills(source, url, options, spinner) {
4369
4767
  spinner.start("Discovering skills from well-known endpoint...");
4370
4768
  let skills = [];
@@ -4400,9 +4798,13 @@ async function handleWellKnownSkills(source, url, options, spinner) {
4400
4798
  process.exit(0);
4401
4799
  }
4402
4800
  let selectedSkills;
4801
+ const logWellKnown = (chosen) => logAutoSelectedSkills(chosen.map((s) => ({
4802
+ label: s.installName,
4803
+ description: s.description
4804
+ })));
4403
4805
  if (options.skill?.includes("*")) {
4404
4806
  selectedSkills = skills;
4405
- log.info(`Installing all ${skills.length} skills`);
4807
+ logWellKnown(selectedSkills);
4406
4808
  } else if (options.skill && options.skill.length > 0) {
4407
4809
  selectedSkills = skills.filter((s) => options.skill.some((name) => s.installName.toLowerCase() === name.toLowerCase() || s.name.toLowerCase() === name.toLowerCase()));
4408
4810
  if (selectedSkills.length === 0) {
@@ -4411,13 +4813,9 @@ async function handleWellKnownSkills(source, url, options, spinner) {
4411
4813
  for (const s of skills) log.message(` - ${s.installName}`);
4412
4814
  process.exit(1);
4413
4815
  }
4414
- } else if (skills.length === 1) {
4415
- selectedSkills = skills;
4416
- const firstSkill = skills[0];
4417
- log.info(`Skill: ${import_picocolors.default.cyan(firstSkill.installName)}`);
4418
- } else if (options.yes) {
4816
+ } else if (skills.length === 1 || options.yes) {
4419
4817
  selectedSkills = skills;
4420
- log.info(`Installing all ${skills.length} skills`);
4818
+ logWellKnown(selectedSkills);
4421
4819
  } else {
4422
4820
  const selected = await searchMultiselect({
4423
4821
  message: "Select skills to install",
@@ -4726,15 +5124,26 @@ async function runAdd(args, options = {}) {
4726
5124
  else if (!process.stdin.isTTY) showInstallTip();
4727
5125
  let tempDir = null;
4728
5126
  try {
5127
+ let effectiveSource = source;
5128
+ let notionSourceLabel = null;
5129
+ const notionSkillPageId = parseNotionSkillUrl(source);
5130
+ if (isNotionSource(source) || notionSkillPageId) {
5131
+ const prepared = notionSkillPageId ? await prepareNotionSkillSource(notionSkillPageId) : await prepareNotionPackSource(options);
5132
+ if (!prepared) return;
5133
+ effectiveSource = prepared.rootDir;
5134
+ tempDir = prepared.tempDir;
5135
+ notionSourceLabel = "packCount" in prepared ? `${prepared.packCount} selected Notion pack${prepared.packCount === 1 ? "" : "s"}` : "Notion page";
5136
+ options.skill = ["*"];
5137
+ }
4729
5138
  const spinner$3 = jsonMode ? {
4730
5139
  start: () => {},
4731
5140
  stop: () => {},
4732
5141
  message: () => {}
4733
5142
  } : spinner();
4734
5143
  spinner$3.start("Parsing source…");
4735
- const parsed = parseSource(source);
4736
- let directDownload = parsed.type === "download";
4737
- spinner$3.stop(`Source: ${parsed.type === "local" ? parsed.localPath : parsed.url}${parsed.ref ? ` @ ${import_picocolors.default.yellow(parsed.ref)}` : ""}${parsed.subpath ? ` (${parsed.subpath})` : ""}${parsed.skillFilter ? ` ${import_picocolors.default.dim("@")}${import_picocolors.default.cyan(parsed.skillFilter)}` : ""}`);
5144
+ const parsed = parseSource(effectiveSource);
5145
+ let directDownload = parsed.type === "download" || notionSourceLabel !== null;
5146
+ spinner$3.stop(notionSourceLabel !== null ? `Source: ${notionSourceLabel}` : `Source: ${parsed.type === "local" ? parsed.localPath : parsed.url}${parsed.ref ? ` @ ${import_picocolors.default.yellow(parsed.ref)}` : ""}${parsed.subpath ? ` (${parsed.subpath})` : ""}${parsed.skillFilter ? ` ${import_picocolors.default.dim("@")}${import_picocolors.default.cyan(parsed.skillFilter)}` : ""}`);
4738
5147
  const ownerRepoRaw = parsed.type === "well-known" || parsed.type === "download" ? null : getOwnerRepo(parsed);
4739
5148
  const repoPrivacyPromise = (() => {
4740
5149
  if (parsed.type !== "github") return Promise.resolve(null);
@@ -4864,9 +5273,13 @@ async function runAdd(args, options = {}) {
4864
5273
  emitJsonAndExit(0);
4865
5274
  }
4866
5275
  let selectedSkills;
5276
+ const logChosen = (chosen) => logAutoSelectedSkills(chosen.map((s) => ({
5277
+ label: getSkillDisplayName(s),
5278
+ description: s.description
5279
+ })));
4867
5280
  if (options.skill?.includes("*")) {
4868
5281
  selectedSkills = skills;
4869
- log.info(`Installing all ${skills.length} skills`);
5282
+ logChosen(selectedSkills);
4870
5283
  } else if (options.skill && options.skill.length > 0) {
4871
5284
  selectedSkills = filterSkills(skills, options.skill);
4872
5285
  if (jsonMode) {
@@ -4884,14 +5297,9 @@ async function runAdd(args, options = {}) {
4884
5297
  emitJsonAndExit(1, `No matching skills found for: ${options.skill.join(", ")}`);
4885
5298
  }
4886
5299
  log.info(`Selected ${selectedSkills.length} skill${selectedSkills.length !== 1 ? "s" : ""}: ${selectedSkills.map((s) => import_picocolors.default.cyan(getSkillDisplayName(s))).join(", ")}`);
4887
- } else if (skills.length === 1) {
4888
- selectedSkills = skills;
4889
- const firstSkill = skills[0];
4890
- log.info(`Skill: ${import_picocolors.default.cyan(getSkillDisplayName(firstSkill))}`);
4891
- log.message(import_picocolors.default.dim(firstSkill.description));
4892
- } else if (options.yes) {
5300
+ } else if (skills.length === 1 || options.yes) {
4893
5301
  selectedSkills = skills;
4894
- log.info(`Installing all ${skills.length} skills`);
5302
+ logChosen(selectedSkills);
4895
5303
  } else {
4896
5304
  const sortedSkills = [...skills].sort((a, b) => {
4897
5305
  if (a.pluginName && !b.pluginName) return -1;
@@ -7749,6 +8157,8 @@ ${BOLD}Usage:${RESET} skills <command> [options]
7749
8157
  ${BOLD}Manage Skills:${RESET}
7750
8158
  add <package> Add a skill package (alias: a)
7751
8159
  e.g. vercel-labs/agent-skills
8160
+ notion
8161
+ https://notion.so/<skill-page>
7752
8162
  https://github.com/vercel-labs/agent-skills
7753
8163
  use <package>@<skill>
7754
8164
  Generate a prompt for using one skill without installing it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills",
3
- "version": "1.5.26",
3
+ "version": "1.7.0",
4
4
  "description": "The open agent skills ecosystem",
5
5
  "type": "module",
6
6
  "bin": {