From aef62baf46167dfb81232bca75633c13c1e78a29 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 10 Oct 2026 15:35:00 +1100 Subject: [PATCH 1/6] feat(pnpm): show published package Skills on Skill pages --- .../app/utils/package-skill-setup.ts | 1 + .../learn/author-npm-package-skills.md | 35 +++- .../registry/app/components/SkillDetail.vue | 12 ++ .../app/components/_SkillPnpmPackage.vue | 28 +++ .../server/api/skill-package/[...slug].get.ts | 47 +++++ .../server/presenters/pnpm-package.ts | 7 + .../registry/server/schemas/pnpm-package.ts | 4 + layers/registry/server/utils/pnpm-package.ts | 171 ++++++++++++++++++ layers/registry/shared/pnpm-package.ts | 4 + test/unit/pnpm-package-panel.nuxt.test.ts | 21 +++ test/unit/pnpm-package.test.ts | 62 +++++++ 11 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 layers/registry/app/components/_SkillPnpmPackage.vue create mode 100644 layers/registry/server/api/skill-package/[...slug].get.ts create mode 100644 layers/registry/server/presenters/pnpm-package.ts create mode 100644 layers/registry/server/schemas/pnpm-package.ts create mode 100644 layers/registry/server/utils/pnpm-package.ts create mode 100644 layers/registry/shared/pnpm-package.ts create mode 100644 test/unit/pnpm-package-panel.nuxt.test.ts create mode 100644 test/unit/pnpm-package.test.ts diff --git a/layers/marketing/app/utils/package-skill-setup.ts b/layers/marketing/app/utils/package-skill-setup.ts index d1f6de2a..10427abf 100644 --- a/layers/marketing/app/utils/package-skill-setup.ts +++ b/layers/marketing/app/utils/package-skill-setup.ts @@ -133,6 +133,7 @@ export function packageSkillGuide(setup: PackageSkillSetup) { `Read ${packageSkillSource}.`, `Draft a Skill for ${setup.package}, published through ${config.label}, using this repository.`, `Read ${config.manifest}, the public API, and the current official documentation.`, + ...(setup.ecosystem === 'npm' ? ['Save each Skill in skills//SKILL.md beside package.json, so pnpm can discover it.', 'Include the Skill and its linked files in the published tarball. Read https://pnpm.io/agent-skills for pnpm approval and linking.'] : []), 'Show the draft for review before replacing existing Skills.', ].join('\n\n'), } diff --git a/layers/marketing/content/learn/author-npm-package-skills.md b/layers/marketing/content/learn/author-npm-package-skills.md index f9e96959..d1906151 100644 --- a/layers/marketing/content/learn/author-npm-package-skills.md +++ b/layers/marketing/content/learn/author-npm-package-skills.md @@ -7,7 +7,7 @@ relatedPages: - path: /skills title: Browse skills createdAt: 2026-05-13 -updatedAt: 2026-10-05 +updatedAt: 2026-10-10 --- ## 1. Draft your Skill @@ -18,6 +18,8 @@ updatedAt: 2026-10-05 Point your agent at the directory containing your package's `package.json`. For a monorepo, use the individual package directory. Ask it to save the draft in `skills/your-skill/SKILL.md`. +Keep `skills/` beside that package's `package.json`. +[pnpm 12.11 and newer](https://pnpm.io/agent-skills) discover Skills one level deep at `skills//SKILL.md`. Describe the tasks your package handles and the mistakes agents should avoid. Check public exports, TypeScript types, supported runtimes, and version limits. @@ -52,7 +54,36 @@ See the [npm pack reference](https://docs.npmjs.com/cli/v11/commands/npm-pack). Commit the reviewed Skill and publish through your normal release process. Link to its repository directory from your package README. -Installing the npm package does not automatically install the Skill into an agent. Keep the Skill link visible so developers can choose how to use it. +Developers using pnpm 12.11 or newer can approve Skills from direct dependencies with `pnpm approve`. +pnpm records the choice in `pnpm-workspace.yaml`: + +```yaml +permissions: + your-package: + skills: true +``` + +Replace `your-package` with the published package name, including its scope. +Merge this entry into existing permissions. +Approval covers every Skill in the package and every later version. +Ask developers to read the Skills before approving. + +pnpm links approved Skills into existing Agent directories at the workspace root. +Developers can choose directories with `skills.dirs` in `pnpm-workspace.yaml`: + +```yaml +skills: + dirs: + - .agents/skills +``` + +Merge this setting into the existing workspace file, then run `pnpm install`. +For `your-package`, the example Skill appears at `.agents/skills/pnpm-your-package-your-skill/SKILL.md`. +A scoped package replaces `/` with `+` in the link name. +pnpm owns these links and removes them when the package or approval disappears. +Global installs, `pnpm dlx`, and `pnpm deploy` do not link Skills. +See [pnpm's approval and linking rules](https://pnpm.io/agent-skills). + When your API changes, review the Skill alongside your package documentation. diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index 0c33c78a..1b591cc1 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -4,6 +4,7 @@ import type { BehaviorReading, BehaviorReadingsResponse } from '#shared/behavior import type { RunCheckFlagsResponse } from '#shared/run-check-flags' import type { TrendingAward } from '#shared/trending-award' import type { SkillDemoView } from '../../server/utils/skill-demos' +import type { PnpmPackage } from '../../shared/pnpm-package' import type { ZipState } from '../utils/skill-zip' import type { SkillBehavior } from './_SkillBehaviors.vue' import { formatTimeAgo } from '@vueuse/core' @@ -27,6 +28,7 @@ import { resolveSkillZipEntries } from '../utils/skill-zip' import SkillBehaviors from './_SkillBehaviors.vue' import SkillCommandPanel from './_SkillCommandPanel.vue' import SkillDemo from './_SkillDemo.vue' +import SkillPnpmPackage from './_SkillPnpmPackage.vue' import SkillReceiptsPanel from './_SkillReceiptsPanel.vue' import SkillStarTrend from './_SkillStarTrend.vue' import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue' @@ -328,6 +330,14 @@ const liveSkillFetch = useAsyncData( }, ) +// Both responsive command blocks share one optional package check after hydration. +const packageFetch = useAsyncData( + () => `skill-package:${slug.value}`, + () => $fetch(`/api/skill-package/${slug.value}`), + { server: false, default: () => null }, +) +const pnpmPackage = computed(() => packageFetch.data.value?._tag === 'Found' ? packageFetch.data.value : null) + // Keep complete SSR for search and link previews. Client navigation renders // the loading state immediately while these independent requests run together. // A deep link renders its file on the server, so the link shows that file @@ -1788,6 +1798,7 @@ useHead(computed(() => ({ :source-url="skillFileUrl || githubUrl" @copy="copySkillCommand" /> +
({ @download="downloadSkillZip" @copy="copySkillCommand" /> +
+import type { PnpmPackage } from '../../shared/pnpm-package' + +const { published } = defineProps<{ published: Extract | null }>() + + + diff --git a/layers/registry/server/api/skill-package/[...slug].get.ts b/layers/registry/server/api/skill-package/[...slug].get.ts new file mode 100644 index 00000000..eb4333d0 --- /dev/null +++ b/layers/registry/server/api/skill-package/[...slug].get.ts @@ -0,0 +1,47 @@ +import { cached } from '#shared/server/cache' +import { defineApiHandler } from '#shared/server/handler' +import { presentPnpmPackage } from '../../presenters/pnpm-package' +import { pnpmPackageQuery, pnpmPackageSlug } from '../../schemas/pnpm-package' +import { detectPnpmPackage } from '../../utils/pnpm-package' +import { resolveRepoSourceIdentityFromRow } from '../../utils/repo-source-identity' +import { findSkillWithRow } from '../../utils/skills-registry' + +interface PackageSourceRow { + owner: string + repo: string + source_owner: string | null + source_repo: string | null + default_branch: string | null + rendered_skill_path: string | null + rendered_commit_sha: string | null + source_resolved: number | null +} + +export default defineApiHandler({ + schema: pnpmPackageQuery, + policy: [], + handler: async ({ event }) => { + const slug = pnpmPackageSlug.safeParse(getRouterParam(event, 'slug')) + if (!slug.success) + throw createError({ statusCode: 400, message: 'Use owner/repository/skill.' }) + const found = await findSkillWithRow(event, slug.data, 's.rendered_commit_sha, s.source_resolved') + if (!found) + throw createError({ statusCode: 404, message: 'Skill not found.' }) + const { row } = found + if (!row.rendered_skill_path || row.source_resolved === 0) + return { _tag: 'Absent' } as const + const source = resolveRepoSourceIdentityFromRow({ owner: row.owner, repo: row.repo }, row) + return cached({ + storage: useStorage('edge-cache'), + key: `skills:pnpm:v1:${source.owner}/${source.repo}/${row.rendered_commit_sha ?? row.default_branch}:${row.rendered_skill_path}`, + ttlSeconds: 3600, + compute: async () => { + const result = await detectPnpmPackage({ ...source, ref: row.rendered_commit_sha ?? row.default_branch ?? 'HEAD', skillPath: row.rendered_skill_path! }, fetch) + if (result._tag === 'Unavailable') + throw createError({ statusCode: 503, message: 'Package details are unavailable. Try again later.' }) + return result + }, + }) + }, + presenter: presentPnpmPackage, +}) diff --git a/layers/registry/server/presenters/pnpm-package.ts b/layers/registry/server/presenters/pnpm-package.ts new file mode 100644 index 00000000..8db9aba2 --- /dev/null +++ b/layers/registry/server/presenters/pnpm-package.ts @@ -0,0 +1,7 @@ +import type { PnpmPackage } from '../../shared/pnpm-package' + +export function presentPnpmPackage(result: PnpmPackage): PnpmPackage { + return result._tag === 'Found' + ? { _tag: 'Found', package: result.package, version: result.version, skill: result.skill } + : { _tag: result._tag } +} diff --git a/layers/registry/server/schemas/pnpm-package.ts b/layers/registry/server/schemas/pnpm-package.ts new file mode 100644 index 00000000..933a5657 --- /dev/null +++ b/layers/registry/server/schemas/pnpm-package.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const pnpmPackageQuery = z.object({}) +export const pnpmPackageSlug = z.string().regex(/^[\w.-]+\/[\w.-]+\/[\w.-]+$/) diff --git a/layers/registry/server/utils/pnpm-package.ts b/layers/registry/server/utils/pnpm-package.ts new file mode 100644 index 00000000..ab049746 --- /dev/null +++ b/layers/registry/server/utils/pnpm-package.ts @@ -0,0 +1,171 @@ +import type { PnpmPackage } from '../../shared/pnpm-package' +import { z } from 'zod' + +const packageName = z.string().max(214).regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/) +const manifestSchema = z.object({ name: packageName, private: z.boolean().optional() }) +const publishedSchema = z.object({ + name: packageName, + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + repository: z.union([z.string(), z.object({ url: z.string(), directory: z.string().optional() })]), + dist: z.object({ tarball: z.url() }), +}) +const ABSENT = { _tag: 'Absent' } as const +const UNAVAILABLE = { _tag: 'Unavailable' } as const +const MAX_COMPRESSED_BYTES = 8 * 1024 * 1024 +const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 + +interface Source { owner: string, repo: string, ref: string, skillPath: string } + +/** Inspect published bytes, rather than the AI-derived target_package classification. */ +export async function detectPnpmPackage(source: Source, fetcher: typeof fetch): Promise { + const match = /^(?:(.+)\/)?skills\/([^/]+)\/SKILL\.md$/.exec(source.skillPath) + if (!match || source.skillPath.split('/').some(part => part === '.' || part === '..' || part.includes('\\'))) + return ABSENT + const directory = match[1] ?? '' + // A nested Skill is not one of pnpm's immediate children of skills/. + if (directory.split('/').includes('skills')) + return ABSENT + const skill = match[2]! + const manifestPath = [directory, 'package.json'].filter(Boolean).join('/') + const manifestUrl = `https://raw.githubusercontent.com/${encodeURIComponent(source.owner)}/${encodeURIComponent(source.repo)}/${encodeURIComponent(source.ref)}/${manifestPath.split('/').map(encodeURIComponent).join('/')}` + const manifestRead = await readJson(manifestUrl, fetcher) + if (manifestRead._tag !== 'Read') + return manifestRead + const manifest = manifestSchema.safeParse(manifestRead.value) + if (!manifest.success || manifest.data.private) + return ABSENT + const publication = await readJson(`https://registry.npmjs.org/${encodeURIComponent(manifest.data.name)}/latest`, fetcher) + if (publication._tag !== 'Read') + return publication + const published = publishedSchema.safeParse(publication.value) + if (!published.success || published.data.name !== manifest.data.name) + return ABSENT + const repository = published.data.repository + const repositoryUrl = typeof repository === 'string' ? repository : repository.url + const identity = /^(?:git\+)?https:\/\/github\.com\/([^/]+)\/([^/#]+?)(?:\.git)?\/?$/.exec(repositoryUrl) + ?? /^(?:git\+)?(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/#]+?)(?:\.git)?$/.exec(repositoryUrl) + if (!identity || identity[1]!.toLowerCase() !== source.owner.toLowerCase() || identity[2]!.toLowerCase() !== source.repo.toLowerCase()) + return ABSENT + if (typeof repository !== 'string' && repository.directory !== undefined && repository.directory.replace(/^\.\//, '').replace(/\/$/, '') !== directory) + return ABSENT + const tarball = new URL(published.data.dist.tarball) + if (tarball.protocol !== 'https:' || tarball.hostname !== 'registry.npmjs.org' || tarball.port || tarball.username || tarball.password) + return ABSENT + const sent = await attempt(fetcher(tarball.href, { redirect: 'manual', signal: AbortSignal.timeout(8000) })) + if (sent._tag === 'Unavailable') + return UNAVAILABLE + const response = sent.value + if (!response.ok || !response.body) { + await response.body?.cancel() + return UNAVAILABLE + } + // Bound both network bytes and decompressed bytes before parsing any archive header. + const compressed = response.body.pipeThrough(byteLimit(MAX_COMPRESSED_BYTES)) + const gunzip = new DecompressionStream('gzip') + const archive = await attempt(readBounded(compressed.pipeThrough({ writable: gunzip.writable as WritableStream, readable: gunzip.readable }), MAX_ARCHIVE_BYTES)) + if (archive._tag === 'Unavailable') + return UNAVAILABLE + const bundled = containsSkill(archive.value, `package/skills/${skill}/SKILL.md`) + if (bundled === null) + return UNAVAILABLE + return bundled ? { _tag: 'Found', package: published.data.name, version: published.data.version, skill } : ABSENT +} + +type JsonRead = { _tag: 'Read', value: unknown } | typeof ABSENT | typeof UNAVAILABLE + +async function readJson(url: string, fetcher: typeof fetch): Promise { + const sent = await attempt(fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(4000) })) + if (sent._tag === 'Unavailable') + return UNAVAILABLE + const response = sent.value + // Transport failures remain retryable. They must never cache a negative package result. + if (response.status !== 404 && !response.ok) { + await response.body?.cancel() + return UNAVAILABLE + } + if (response.status === 404) { + await response.body?.cancel() + return ABSENT + } + if (!response.body) + return UNAVAILABLE + const bytes = await attempt(readBounded(response.body, 1024 * 1024)) + if (bytes._tag === 'Unavailable') + return UNAVAILABLE + const parsed = await attempt(Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes.value)) as unknown)) + return parsed._tag === 'Read' ? parsed : UNAVAILABLE +} + +function attempt(promise: Promise): Promise<{ _tag: 'Read', value: T } | typeof UNAVAILABLE> { + return promise.then(value => ({ _tag: 'Read' as const, value })).catch(() => { + // Expected transport, body, and parse failures become a retryable result, never an Absent result. + return UNAVAILABLE + }) +} + +function byteLimit(max: number): TransformStream { + let size = 0 + return new TransformStream({ + transform(chunk, controller) { + size += chunk.byteLength + if (size > max) + throw new Error('Package archive exceeds its byte limit.') + controller.enqueue(chunk) + }, + }) +} + +async function readBounded(stream: ReadableStream, max: number): Promise { + return new Uint8Array(await new Response(stream.pipeThrough(byteLimit(max))).arrayBuffer()) +} + +/** A regular nonempty file only. PAX and GNU path records apply to the next header. */ +function containsSkill(bytes: Uint8Array, target: string): boolean | null { + const decoder = new TextDecoder() + const text = (start: number, size: number) => decoder.decode(bytes.subarray(start, start + size)).split('\0')[0]! + let nextPath: string | undefined + let found = false + for (let offset = 0; offset + 512 <= bytes.length;) { + const header = bytes.subarray(offset, offset + 512) + if (header.every(byte => byte === 0)) + return found + const sizeText = text(offset + 124, 12).trim() + if (!/^[0-7]+$/.test(sizeText)) + return null + const size = Number.parseInt(sizeText, 8) + const start = offset + 512 + if (start + Math.ceil(size / 512) * 512 > bytes.length) + return null + const checksum = Number.parseInt(text(offset + 148, 8).trim(), 8) + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0) + if (checksum !== sum) + return null + const type = String.fromCharCode(header[156]!) + if (type === 'L') { + nextPath = text(start, size).replace(/\n$/, '') + } + else if (type === 'x') { + for (let position = start; position < start + size;) { + const space = bytes.indexOf(32, position) + if (space < position || space >= start + size) + return null + const length = Number(decoder.decode(bytes.subarray(position, space))) + if (!Number.isSafeInteger(length) || length <= space - position + 1 || position + length > start + size) + return null + const record = decoder.decode(bytes.subarray(space + 1, position + length - 1)) + if (record.startsWith('path=')) + nextPath = record.slice(5) + position += length + } + } + else if (type !== 'g') { + const prefix = text(offset + 345, 155) + const path = nextPath ?? [prefix, text(offset, 100)].filter(Boolean).join('/') + if (path === target) + found = (type === '0' || type === '\0') && size > 0 + nextPath = undefined + } + offset = start + Math.ceil(size / 512) * 512 + } + return null +} diff --git a/layers/registry/shared/pnpm-package.ts b/layers/registry/shared/pnpm-package.ts new file mode 100644 index 00000000..c1037904 --- /dev/null +++ b/layers/registry/shared/pnpm-package.ts @@ -0,0 +1,4 @@ +export type PnpmPackage + = | { _tag: 'Found', package: string, version: string, skill: string } + | { _tag: 'Absent' } + | { _tag: 'Unavailable' } diff --git a/test/unit/pnpm-package-panel.nuxt.test.ts b/test/unit/pnpm-package-panel.nuxt.test.ts new file mode 100644 index 00000000..5d0c4ba5 --- /dev/null +++ b/test/unit/pnpm-package-panel.nuxt.test.ts @@ -0,0 +1,21 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import SkillPnpmPackage from '../../layers/registry/app/components/_SkillPnpmPackage.vue' + +describe('pnpm package install option', () => { + it('stays hidden when no published package includes the Skill', async () => { + const wrapper = await mountSuspended(SkillPnpmPackage, { props: { published: null } }) + expect(wrapper.find('details').exists()).toBe(false) + wrapper.unmount() + }) + + it('shows the published version and package-wide approval limit', async () => { + const wrapper = await mountSuspended(SkillPnpmPackage, { props: { published: { _tag: 'Found', package: '@acme/kit', version: '1.2.3', skill: 'auth' } } }) + expect(wrapper.find('summary').text()).toBe('Use with pnpm') + expect(wrapper.text()).toContain('pnpm add @acme/kit@1.2.3') + expect(wrapper.text()).toContain('pnpm approve') + expect(wrapper.text()).toContain('Approval covers every Skill and later version of the package.') + expect(wrapper.find('a').attributes('href')).toBe('https://www.npmjs.com/package/@acme/kit/v/1.2.3') + wrapper.unmount() + }) +}) diff --git a/test/unit/pnpm-package.test.ts b/test/unit/pnpm-package.test.ts new file mode 100644 index 00000000..b2f02d70 --- /dev/null +++ b/test/unit/pnpm-package.test.ts @@ -0,0 +1,62 @@ +import { detectPnpmPackage } from '../../layers/registry/server/utils/pnpm-package' +import { tarGzFixture } from '../fixtures/tar-archive' + +const source = { owner: 'acme', repo: 'kit', ref: 'abc123', skillPath: 'packages/ui/skills/auth/SKILL.md' } +const manifest = { name: '@acme/ui', version: '1.0.0' } +const published = { + name: '@acme/ui', + version: '1.0.0', + repository: { url: 'git+https://github.com/acme/kit.git', directory: 'packages/ui' }, + dist: { tarball: 'https://registry.npmjs.org/@acme/ui/-/ui-1.0.0.tgz' }, +} + +function reads(options: { manifest?: unknown, published?: unknown, path?: string, typeflag?: string, status?: number } = {}) { + const urls: string[] = [] + const fetcher: typeof fetch = async (input) => { + const url = String(input) + urls.push(url) + if (url.includes('raw.githubusercontent.com')) + return Response.json(options.manifest ?? manifest) + if (url.endsWith('/latest')) + return Response.json(options.published ?? published, { status: options.status ?? 200 }) + return new Response(tarGzFixture('package', [{ path: options.path ?? 'skills/auth/SKILL.md', typeflag: options.typeflag, bytes: new TextEncoder().encode('---\nname: auth\n---\nUse auth.') }])) + } + return { fetcher, urls } +} + +describe('published pnpm Skills', () => { + it('detects a scoped monorepo package from its published archive', async () => { + const { fetcher } = reads() + expect(await detectPnpmPackage(source, fetcher)).toEqual({ + _tag: 'Found', + package: '@acme/ui', + version: '1.0.0', + skill: 'auth', + }) + }) + + it.each([ + { manifest: { ...manifest, private: true } }, + { manifest: { name: 'bad;echo secret' } }, + { published: { ...published, repository: { url: 'https://github.com/other/kit' } } }, + { published: { ...published, name: '@other/ui' } }, + { published: { ...published, dist: { tarball: 'https://evil.test/archive.tgz' } } }, + { path: 'SKILL.md' }, + { path: 'skills/auth/nested/SKILL.md' }, + { typeflag: '2' }, + { status: 404 }, + ])('does not offer pnpm for unsupported publication %j', async (options) => { + expect(await detectPnpmPackage(source, reads(options).fetcher)).toEqual({ _tag: 'Absent' }) + }) + + it('does not read manifests for a root or nested Skill outside pnpm layout', async () => { + const { fetcher, urls } = reads() + expect(await detectPnpmPackage({ ...source, skillPath: 'SKILL.md' }, fetcher)).toEqual({ _tag: 'Absent' }) + expect(await detectPnpmPackage({ ...source, skillPath: 'skills/group/auth/SKILL.md' }, fetcher)).toEqual({ _tag: 'Absent' }) + expect(urls).toEqual([]) + }) + + it('keeps upstream outages distinct from a package without Skills', async () => { + expect(await detectPnpmPackage(source, reads({ status: 503 }).fetcher)).toEqual({ _tag: 'Unavailable' }) + }) +}) From 270848538a828e9cfb24c01131c926ecc541093b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 10 Oct 2026 15:54:14 +1100 Subject: [PATCH 2/6] fix(pnpm): cache registry reads and accept valid Skill names --- .../server/api/skill-package/[...slug].get.ts | 17 +++++++++-------- layers/registry/server/schemas/pnpm-package.ts | 2 +- test/unit/pnpm-package.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/layers/registry/server/api/skill-package/[...slug].get.ts b/layers/registry/server/api/skill-package/[...slug].get.ts index eb4333d0..4188d986 100644 --- a/layers/registry/server/api/skill-package/[...slug].get.ts +++ b/layers/registry/server/api/skill-package/[...slug].get.ts @@ -24,18 +24,19 @@ export default defineApiHandler({ const slug = pnpmPackageSlug.safeParse(getRouterParam(event, 'slug')) if (!slug.success) throw createError({ statusCode: 400, message: 'Use owner/repository/skill.' }) - const found = await findSkillWithRow(event, slug.data, 's.rendered_commit_sha, s.source_resolved') - if (!found) - throw createError({ statusCode: 404, message: 'Skill not found.' }) - const { row } = found - if (!row.rendered_skill_path || row.source_resolved === 0) - return { _tag: 'Absent' } as const - const source = resolveRepoSourceIdentityFromRow({ owner: row.owner, repo: row.repo }, row) return cached({ storage: useStorage('edge-cache'), - key: `skills:pnpm:v1:${source.owner}/${source.repo}/${row.rendered_commit_sha ?? row.default_branch}:${row.rendered_skill_path}`, + // Cache the registry lookup too, so repeated views do not repeat D1 reads. + key: `skills:pnpm:v2:${slug.data}`, ttlSeconds: 3600, compute: async () => { + const found = await findSkillWithRow(event, slug.data, 's.rendered_commit_sha, s.source_resolved') + if (!found) + throw createError({ statusCode: 404, message: 'Skill not found.' }) + const { row } = found + if (!row.rendered_skill_path || row.source_resolved === 0) + return { _tag: 'Absent' } as const + const source = resolveRepoSourceIdentityFromRow({ owner: row.owner, repo: row.repo }, row) const result = await detectPnpmPackage({ ...source, ref: row.rendered_commit_sha ?? row.default_branch ?? 'HEAD', skillPath: row.rendered_skill_path! }, fetch) if (result._tag === 'Unavailable') throw createError({ statusCode: 503, message: 'Package details are unavailable. Try again later.' }) diff --git a/layers/registry/server/schemas/pnpm-package.ts b/layers/registry/server/schemas/pnpm-package.ts index 933a5657..fbe310be 100644 --- a/layers/registry/server/schemas/pnpm-package.ts +++ b/layers/registry/server/schemas/pnpm-package.ts @@ -1,4 +1,4 @@ import { z } from 'zod' export const pnpmPackageQuery = z.object({}) -export const pnpmPackageSlug = z.string().regex(/^[\w.-]+\/[\w.-]+\/[\w.-]+$/) +export const pnpmPackageSlug = z.string().max(512).regex(/^[\w.-]+\/[\w.-]+\/[^/\\]+$/).refine(value => !['.', '..'].includes(value.split('/')[2]!)) diff --git a/test/unit/pnpm-package.test.ts b/test/unit/pnpm-package.test.ts index b2f02d70..d8a80e6d 100644 --- a/test/unit/pnpm-package.test.ts +++ b/test/unit/pnpm-package.test.ts @@ -1,3 +1,4 @@ +import { pnpmPackageSlug } from '../../layers/registry/server/schemas/pnpm-package' import { detectPnpmPackage } from '../../layers/registry/server/utils/pnpm-package' import { tarGzFixture } from '../fixtures/tar-archive' @@ -10,6 +11,16 @@ const published = { dist: { tarball: 'https://registry.npmjs.org/@acme/ui/-/ui-1.0.0.tgz' }, } +it('accepts punctuation and Unicode in a Skill name', () => { + expect(pnpmPackageSlug.parse('acme/kit/c++-helpers')).toBe('acme/kit/c++-helpers') + expect(pnpmPackageSlug.parse('acme/kit/認証')).toBe('acme/kit/認証') +}) + +it('rejects traversal, extra segments, and oversized Skill slugs', () => { + for (const slug of ['acme/kit/..', 'acme/kit/.', 'acme/kit/a/b', 'acme/kit/a\\b', `acme/kit/${'a'.repeat(513)}`]) + expect(pnpmPackageSlug.safeParse(slug).success).toBe(false) +}) + function reads(options: { manifest?: unknown, published?: unknown, path?: string, typeflag?: string, status?: number } = {}) { const urls: string[] = [] const fetcher: typeof fetch = async (input) => { From 6a378cb0af43a272e17e773121bd35637b8aab4d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 10 Oct 2026 17:18:39 +1100 Subject: [PATCH 3/6] feat(npm): add package setup tabs and pnpm compatibility --- COPY.md | 9 ++++ layers/marketing/app/pages/cli/index.vue | 9 ++++ .../registry/app/components/SkillDetail.vue | 5 +- .../app/components/_SkillCommandPanel.vue | 27 +++++++++-- .../app/components/_SkillPnpmPackage.vue | 44 +++++++++++------ test/unit/pnpm-package-panel.nuxt.test.ts | 48 +++++++++++++++---- 6 files changed, 112 insertions(+), 30 deletions(-) diff --git a/COPY.md b/COPY.md index cd2bd791..74a90d61 100644 --- a/COPY.md +++ b/COPY.md @@ -269,3 +269,12 @@ section above, then delete it from this list. is "Skilld is a curated registry of agent skills written by real people in the GitHub repos you already depend on." Both are true and they are not the same sentence. Decide which is canonical for a one-sentence slot, or record what each is for. + +### npm package Skills + +The install panel shows an npm option only when the published package includes the Skill. +Keep run as the default. Offer pnpm and skills-npm setup within the npm option. +Show pnpm's logo beside its name. State each tool's minimum version and setup changes. +Explain that pnpm approval covers all package Skills and later package versions. +The CLI feature list links #234 as the required change until a supporting release has a version. +Describe ownership: doctor recognizes pnpm-managed Skills; pnpm controls their links and updates. diff --git a/layers/marketing/app/pages/cli/index.vue b/layers/marketing/app/pages/cli/index.vue index 9ba66821..17d9703a 100644 --- a/layers/marketing/app/pages/cli/index.vue +++ b/layers/marketing/app/pages/cli/index.vue @@ -342,6 +342,15 @@ const codeClass = 'font-mono text-xs text-default' No runtime to install, and it starts in under a millisecond. A curl or PowerShell install upgrades itself from signed releases. An npm install runs the same executable, and npm handles its upgrades.
+
+
+
+
+ CLI builds containing #234 recognize pnpm-managed Skills in doctor. pnpm keeps control of their links and updates. +
+
No telemetry diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index 1b591cc1..3ba12193 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -28,7 +28,6 @@ import { resolveSkillZipEntries } from '../utils/skill-zip' import SkillBehaviors from './_SkillBehaviors.vue' import SkillCommandPanel from './_SkillCommandPanel.vue' import SkillDemo from './_SkillDemo.vue' -import SkillPnpmPackage from './_SkillPnpmPackage.vue' import SkillReceiptsPanel from './_SkillReceiptsPanel.vue' import SkillStarTrend from './_SkillStarTrend.vue' import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue' @@ -1791,6 +1790,7 @@ useHead(computed(() => ({ v-model="commandMode" :run-url="runUrl" :install-command="installCmd" + :published="pnpmPackage" :run-copied="copied" :install-copied="installCopied" :copy-error="commandCopyError" @@ -1798,7 +1798,6 @@ useHead(computed(() => ({ :source-url="skillFileUrl || githubUrl" @copy="copySkillCommand" /> -
({ :zip-state="zipState" :run-url="runUrl" :install-command="installCmd" + :published="pnpmPackage" :run-copied="copied" :install-copied="installCopied" :copy-error="commandCopyError" @@ -1825,7 +1825,6 @@ useHead(computed(() => ({ @download="downloadSkillZip" @copy="copySkillCommand" /> -
import type { RunCheckFlag } from '#shared/run-check-flags' +import type { PnpmPackage } from '../../shared/pnpm-package' import type { ZipState } from '../utils/skill-zip' +import SkillPnpmPackage from './_SkillPnpmPackage.vue' import SkillRunFlag from './_SkillRunFlag.vue' import SkillRunPrompt from './_SkillRunPrompt.vue' type CommandMode = 'run' | 'install' -type InstallTarget = 'local' | 'claude' | 'chatgpt' +type InstallTarget = 'local' | 'claude' | 'chatgpt' | 'npm' const { runUrl, @@ -18,6 +20,7 @@ const { zipState = { _tag: 'idle' }, runFlag = null, sourceUrl = '', + published = null, } = defineProps<{ /** The Skill page. The Agent fetches it and receives the SKILL.md as markdown. */ runUrl: string @@ -34,6 +37,7 @@ const { runFlag?: RunCheckFlag | null /** The SKILL.md on GitHub, which the run flag links. */ sourceUrl?: string + published?: Extract | null }>() const emit = defineEmits<{ @@ -42,18 +46,24 @@ const emit = defineEmits<{ }>() const installTarget = ref('local') -const installTargets = [ +watch(() => published, (value) => { + if (!value && installTarget.value === 'npm') + installTarget.value = 'local' +}) +const installTargets = computed(() => [ { label: 'Terminal', value: 'local' }, { label: 'Claude', value: 'claude' }, { label: 'ChatGPT', value: 'chatgpt' }, -] satisfies { label: string, value: InstallTarget }[] + ...(published ? [{ label: 'npm', value: 'npm' as const }] : []), +] satisfies { label: string, value: InstallTarget }[]) // Where each web app takes an uploaded Skill ZIP. -const uploadSteps: Record, string> = { +const uploadSteps: Record<'claude' | 'chatgpt', string> = { claude: 'Upload it in Claude under Settings › Capabilities › Skills.', chatgpt: 'Upload it in ChatGPT under Skills › Create › Upload from your computer.', } // The next step past one Skill: let the same client search the whole registry. const registrySetup: Record = { + npm: { to: '/developers', label: 'Let your agent search the registry' }, local: { to: '/developers', label: 'Let your agent search the registry' }, claude: { to: '/developers?setup=mcp&app=claude', label: 'Search the registry from Claude' }, chatgpt: { to: '/developers?setup=mcp&app=chatgpt', label: 'Search the registry from ChatGPT' }, @@ -169,6 +179,7 @@ function copyFrom(next: CommandMode) { />
+