[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: auto-detect `package.json` skills (#464)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Max
autofix-ci[bot]
Daniel Roe
and committed by
GitHub
88b8e90e f1498257

+909 -7
+29
app/components/PackageSkillsCard.vue
··· 1 + <script setup lang="ts"> 2 + import type { SkillListItem } from '#shared/types' 3 + 4 + defineProps<{ 5 + skills: SkillListItem[] 6 + packageName: string 7 + version?: string 8 + }>() 9 + 10 + const skillsModal = useModal('skills-modal') 11 + </script> 12 + 13 + <template> 14 + <section v-if="skills.length" id="skills" class="scroll-mt-20"> 15 + <h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-3"> 16 + {{ $t('package.skills.title') }} 17 + </h2> 18 + <button 19 + type="button" 20 + class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200" 21 + @click="skillsModal.open()" 22 + > 23 + <span class="i-custom:agent-skills w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" /> 24 + <span class="text-fg-muted">{{ 25 + $t('package.skills.skills_available', { count: skills.length }, skills.length) 26 + }}</span> 27 + </button> 28 + </section> 29 + </template>
+236
app/components/PackageSkillsModal.vue
··· 1 + <script setup lang="ts"> 2 + import type { SkillListItem } from '#shared/types' 3 + 4 + const props = defineProps<{ 5 + skills: SkillListItem[] 6 + packageName: string 7 + version?: string 8 + }>() 9 + 10 + function getSkillSourceUrl(skill: SkillListItem): string { 11 + const base = `/code/${props.packageName}` 12 + const versionPath = props.version ? `/v/${props.version}` : '' 13 + return `${base}${versionPath}/skills/${skill.dirName}/SKILL.md` 14 + } 15 + 16 + const expandedSkills = ref<Set<string>>(new Set()) 17 + 18 + function toggleSkill(dirName: string) { 19 + if (expandedSkills.value.has(dirName)) { 20 + expandedSkills.value.delete(dirName) 21 + } else { 22 + expandedSkills.value.add(dirName) 23 + } 24 + expandedSkills.value = new Set(expandedSkills.value) 25 + } 26 + 27 + type InstallMethod = 'skills-npm' | 'skills-cli' 28 + const selectedMethod = ref<InstallMethod>('skills-npm') 29 + 30 + const baseUrl = computed(() => 31 + typeof window !== 'undefined' ? window.location.origin : 'https://npmx.dev', 32 + ) 33 + 34 + const installCommand = computed(() => { 35 + if (!props.skills.length) return null 36 + return `npx skills add ${baseUrl.value}/${props.packageName}` 37 + }) 38 + 39 + const { copied, copy } = useClipboard({ copiedDuring: 2000 }) 40 + const copyCommand = () => installCommand.value && copy(installCommand.value) 41 + 42 + function getWarningTooltip(skill: SkillListItem): string | undefined { 43 + if (!skill.warnings?.length) return undefined 44 + return skill.warnings.map(w => w.message).join(', ') 45 + } 46 + </script> 47 + 48 + <template> 49 + <Modal :modal-title="$t('package.skills.title')" id="skills-modal" class="sm:max-w-2xl"> 50 + <!-- Install header with tabs --> 51 + <div class="flex flex-wrap items-center justify-between gap-2 mb-3"> 52 + <h3 class="text-xs text-fg-subtle uppercase tracking-wider"> 53 + {{ $t('package.skills.install') }} 54 + </h3> 55 + <div 56 + class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md" 57 + role="tablist" 58 + :aria-label="$t('package.skills.installation_method')" 59 + > 60 + <button 61 + role="tab" 62 + :aria-selected="selectedMethod === 'skills-npm'" 63 + :tabindex="selectedMethod === 'skills-npm' ? 0 : -1" 64 + type="button" 65 + class="px-2 py-1 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 66 + :class=" 67 + selectedMethod === 'skills-npm' 68 + ? 'bg-bg border-border shadow-sm text-fg' 69 + : 'border-transparent text-fg-subtle hover:text-fg' 70 + " 71 + @click="selectedMethod = 'skills-npm'" 72 + > 73 + skills-npm 74 + </button> 75 + <button 76 + role="tab" 77 + :aria-selected="selectedMethod === 'skills-cli'" 78 + :tabindex="selectedMethod === 'skills-cli' ? 0 : -1" 79 + type="button" 80 + class="px-2 py-1 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 81 + :class=" 82 + selectedMethod === 'skills-cli' 83 + ? 'bg-bg border-border shadow-sm text-fg' 84 + : 'border-transparent text-fg-subtle hover:text-fg' 85 + " 86 + @click="selectedMethod = 'skills-cli'" 87 + > 88 + skills CLI 89 + </button> 90 + </div> 91 + </div> 92 + 93 + <!-- skills-npm: compatible --> 94 + <div 95 + v-if="selectedMethod === 'skills-npm'" 96 + class="flex items-center justify-between gap-2 px-3 py-2.5 sm:px-4 bg-bg-subtle border border-border rounded-lg mb-5" 97 + > 98 + <i18n-t keypath="package.skills.compatible_with" tag="span" class="text-sm text-fg-muted"> 99 + <template #tool> 100 + <code class="font-mono text-fg">skills-npm</code> 101 + </template> 102 + </i18n-t> 103 + <a 104 + href="/skills-npm" 105 + class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors shrink-0" 106 + > 107 + {{ $t('package.skills.learn_more') }} 108 + <span class="i-carbon:arrow-right w-3 h-3" /> 109 + </a> 110 + </div> 111 + 112 + <!-- skills CLI: terminal command --> 113 + <div 114 + v-else-if="installCommand" 115 + class="bg-bg-subtle border border-border rounded-lg overflow-hidden mb-5" 116 + > 117 + <div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3"> 118 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 119 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 120 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 121 + </div> 122 + <div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 overflow-x-auto"> 123 + <div class="relative group/cmd"> 124 + <code class="font-mono text-sm whitespace-nowrap"> 125 + <span class="text-fg-subtle select-none">$ </span> 126 + <span class="text-fg">npx </span> 127 + <span class="text-fg-muted">skills add {{ baseUrl }}/{{ packageName }}</span> 128 + </code> 129 + <button 130 + type="button" 131 + class="absolute top-0 right-0 px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/cmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 132 + :aria-label="$t('package.get_started.copy_command')" 133 + @click.stop="copyCommand" 134 + > 135 + <span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span> 136 + </button> 137 + </div> 138 + </div> 139 + </div> 140 + 141 + <!-- Skills list --> 142 + <div class="flex items-baseline justify-between gap-2 mb-2"> 143 + <h3 class="text-xs text-fg-subtle uppercase tracking-wider"> 144 + {{ $t('package.skills.available_skills') }} 145 + </h3> 146 + <span class="text-xs text-fg-subtle/60">{{ $t('package.skills.click_to_expand') }}</span> 147 + </div> 148 + <ul class="space-y-0.5 list-none m-0 p-0"> 149 + <li v-for="skill in skills" :key="skill.dirName"> 150 + <button 151 + type="button" 152 + class="w-full flex items-center gap-2 py-1.5 text-start rounded transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 153 + :aria-expanded="expandedSkills.has(skill.dirName)" 154 + @click="toggleSkill(skill.dirName)" 155 + > 156 + <span 157 + class="i-carbon:chevron-right w-3 h-3 text-fg-subtle shrink-0 transition-transform duration-200" 158 + :class="{ 'rotate-90': expandedSkills.has(skill.dirName) }" 159 + aria-hidden="true" 160 + /> 161 + <span class="font-mono text-sm text-fg-muted">{{ skill.name }}</span> 162 + <span 163 + v-if="skill.warnings?.length" 164 + class="i-carbon:warning w-3.5 h-3.5 text-amber-500 shrink-0" 165 + :title="getWarningTooltip(skill)" 166 + /> 167 + </button> 168 + 169 + <!-- Expandable details --> 170 + <div 171 + class="grid transition-[grid-template-rows] duration-200 ease-out" 172 + :class="expandedSkills.has(skill.dirName) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'" 173 + > 174 + <div class="overflow-hidden"> 175 + <div class="ps-5.5 pe-2 pb-2 pt-1 space-y-1.5"> 176 + <!-- Description --> 177 + <p v-if="skill.description" class="text-sm text-fg-subtle"> 178 + {{ skill.description }} 179 + </p> 180 + <p v-else class="text-sm text-fg-subtle/50 italic"> 181 + {{ $t('package.skills.no_description') }} 182 + </p> 183 + 184 + <!-- File counts & warnings --> 185 + <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> 186 + <span v-if="skill.fileCounts?.scripts" class="text-fg-subtle"> 187 + <span class="i-carbon:script size-3 inline-block align-[-2px] me-0.5" />{{ 188 + $t( 189 + 'package.skills.file_counts.scripts', 190 + { count: skill.fileCounts.scripts }, 191 + skill.fileCounts.scripts, 192 + ) 193 + }} 194 + </span> 195 + <span v-if="skill.fileCounts?.references" class="text-fg-subtle"> 196 + <span class="i-carbon:document size-3 inline-block align-[-2px] me-0.5" />{{ 197 + $t( 198 + 'package.skills.file_counts.refs', 199 + { count: skill.fileCounts.references }, 200 + skill.fileCounts.references, 201 + ) 202 + }} 203 + </span> 204 + <span v-if="skill.fileCounts?.assets" class="text-fg-subtle"> 205 + <span class="i-carbon:image size-3 inline-block align-[-2px] me-0.5" />{{ 206 + $t( 207 + 'package.skills.file_counts.assets', 208 + { count: skill.fileCounts.assets }, 209 + skill.fileCounts.assets, 210 + ) 211 + }} 212 + </span> 213 + <template v-for="warning in skill.warnings" :key="warning.message"> 214 + <span class="text-amber-500"> 215 + <span class="i-carbon:warning size-3 inline-block align-[-2px] me-0.5" />{{ 216 + warning.message 217 + }} 218 + </span> 219 + </template> 220 + </div> 221 + 222 + <!-- Source link --> 223 + <NuxtLink 224 + :to="getSkillSourceUrl(skill)" 225 + class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors" 226 + @click.stop 227 + > 228 + <span class="i-carbon:code size-3" />{{ $t('package.skills.view_source') }} 229 + </NuxtLink> 230 + </div> 231 + </div> 232 + </div> 233 + </li> 234 + </ul> 235 + </Modal> 236 + </template>
+1
app/composables/useConnector.ts
··· 1 1 import type { PendingOperation, OperationStatus, OperationType } from '../../cli/src/types' 2 + import { $fetch } from 'ofetch' 2 3 3 4 export interface NewOperation { 4 5 type: OperationType
+34 -1
app/pages/[...package].vue
··· 1 1 <script setup lang="ts"> 2 - import type { NpmVersionDist, PackumentVersion, ReadmeResponse } from '#shared/types' 2 + import type { 3 + NpmVersionDist, 4 + PackumentVersion, 5 + ReadmeResponse, 6 + SkillsListResponse, 7 + } from '#shared/types' 3 8 import type { JsrPackageInfo } from '#shared/types/jsr' 4 9 import { assertValidPackageName } from '#shared/utils/npm' 5 10 import { joinURL } from 'ufo' ··· 65 70 }, 66 71 ) 67 72 onMounted(() => fetchInstallSize()) 73 + 74 + const { data: skillsData } = useLazyFetch<SkillsListResponse>( 75 + () => { 76 + const base = `/skills/${packageName.value}` 77 + const version = requestedVersion.value 78 + return version ? `${base}/v/${version}` : base 79 + }, 80 + { default: () => ({ package: '', version: '', skills: [] }) }, 81 + ) 68 82 69 83 const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) 70 84 const { data: moduleReplacement } = useModuleReplacement(packageName) ··· 836 850 </dd> 837 851 </div> 838 852 </dl> 853 + 854 + <!-- Skills Modal --> 855 + <ClientOnly> 856 + <PackageSkillsModal 857 + :skills="skillsData?.skills ?? []" 858 + :package-name="pkg.name" 859 + :version="displayVersion?.version" 860 + /> 861 + </ClientOnly> 839 862 </header> 840 863 841 864 <!-- Binary-only packages: Show only execute command (no install) --> ··· 976 999 </li> 977 1000 </ul> 978 1001 </section> 1002 + 1003 + <!-- Agent Skills --> 1004 + <ClientOnly> 1005 + <PackageSkillsCard 1006 + v-if="skillsData?.skills?.length" 1007 + :skills="skillsData.skills" 1008 + :package-name="pkg.name" 1009 + :version="displayVersion?.version" 1010 + /> 1011 + </ClientOnly> 979 1012 980 1013 <!-- Download stats --> 981 1014 <PackageWeeklyDownloadStats :packageName />
+12 -3
app/pages/code/[...path].vue
··· 99 99 return `/api/registry/file/${packageName.value}/v/${version.value}/${filePath.value}` 100 100 }) 101 101 102 - const { data: fileContent, status: fileStatus } = useFetch<PackageFileContentResponse>( 103 - () => fileContentUrl.value!, 104 - { immediate: !!fileContentUrl.value }, 102 + const { 103 + data: fileContent, 104 + status: fileStatus, 105 + execute: fetchFileContent, 106 + } = useFetch<PackageFileContentResponse>(() => fileContentUrl.value!, { immediate: false }) 107 + 108 + watch( 109 + fileContentUrl, 110 + url => { 111 + if (url) fetchFileContent() 112 + }, 113 + { immediate: true }, 105 114 ) 106 115 107 116 // Track hash manually since we update it via history API to avoid scroll
+19
i18n/locales/en.json
··· 143 143 "install_size": "Install Size", 144 144 "vulns": "Vulns", 145 145 "updated": "Updated", 146 + "skills": "Skills", 146 147 "view_dependency_graph": "View dependency graph", 147 148 "inspect_dependency_tree": "Inspect dependency tree", 148 149 "size_tooltip": { 149 150 "unpacked": "{size} unpacked size (this package)", 150 151 "total": "{size} total unpacked size (including all {count} dependencies for linux-x64)" 151 152 } 153 + }, 154 + "skills": { 155 + "title": "Agent Skills", 156 + "skills_available": "{count} skill available | {count} skills available", 157 + "view": "View", 158 + "compatible_with": "Compatible with {tool}", 159 + "install": "Install", 160 + "installation_method": "Installation method", 161 + "learn_more": "Learn more", 162 + "available_skills": "Available Skills", 163 + "click_to_expand": "Click to expand", 164 + "no_description": "No description", 165 + "file_counts": { 166 + "scripts": "{count} script | {count} scripts", 167 + "refs": "{count} ref | {count} refs", 168 + "assets": "{count} asset | {count} assets" 169 + }, 170 + "view_source": "View source" 152 171 }, 153 172 "links": { 154 173 "repo": "repo",
+19
lunaria/files/en-US.json
··· 143 143 "install_size": "Install Size", 144 144 "vulns": "Vulns", 145 145 "updated": "Updated", 146 + "skills": "Skills", 146 147 "view_dependency_graph": "View dependency graph", 147 148 "inspect_dependency_tree": "Inspect dependency tree", 148 149 "size_tooltip": { 149 150 "unpacked": "{size} unpacked size (this package)", 150 151 "total": "{size} total unpacked size (including all {count} dependencies for linux-x64)" 151 152 } 153 + }, 154 + "skills": { 155 + "title": "Agent Skills", 156 + "skills_available": "{count} skill available | {count} skills available", 157 + "view": "View", 158 + "compatible_with": "Compatible with {tool}", 159 + "install": "Install", 160 + "installation_method": "Installation method", 161 + "learn_more": "Learn more", 162 + "available_skills": "Available Skills", 163 + "click_to_expand": "Click to expand", 164 + "no_description": "No description", 165 + "file_counts": { 166 + "scripts": "{count} script | {count} scripts", 167 + "refs": "{count} ref | {count} refs", 168 + "assets": "{count} asset | {count} assets" 169 + }, 170 + "view_source": "View source" 152 171 }, 153 172 "links": { 154 173 "repo": "repo",
+1
package.json
··· 72 72 "module-replacements": "2.11.0", 73 73 "nuxt": "4.3.0", 74 74 "nuxt-og-image": "5.1.13", 75 + "ofetch": "1.5.1", 75 76 "perfect-debounce": "2.1.0", 76 77 "sanitize-html": "2.17.0", 77 78 "semver": "7.7.3",
+3
pnpm-lock.yaml
··· 128 128 nuxt-og-image: 129 129 specifier: 5.1.13 130 130 version: 5.1.13(@unhead/vue@2.1.2(vue@3.5.27(typescript@5.9.3)))(magicast@0.5.1)(unstorage@1.17.4(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.2))(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) 131 + ofetch: 132 + specifier: 1.5.1 133 + version: 1.5.1 131 134 perfect-debounce: 132 135 specifier: 2.1.0 133 136 version: 2.1.0
+1
server/plugins/fetch-cache.ts
··· 1 1 import type { H3Event } from 'h3' 2 2 import type { CachedFetchEntry, CachedFetchResult } from '#shared/utils/fetch-cache-config' 3 + import { $fetch } from 'ofetch' 3 4 import { 4 5 FETCH_CACHE_DEFAULT_TTL, 5 6 FETCH_CACHE_STORAGE_BASE,
+104
server/routes/[...path].get.ts
··· 1 + import type { H3Event } from 'h3' 2 + import * as v from 'valibot' 3 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 4 + import { SkillNameSchema } from '#shared/schemas/skills' 5 + import { CACHE_MAX_AGE_ONE_HOUR, CACHE_MAX_AGE_ONE_YEAR } from '#shared/utils/constants' 6 + 7 + const CACHE_VERSION = 1 8 + 9 + /** 10 + * Well-known skills endpoint for `npx skills add` CLI compatibility. 11 + * 12 + * URL patterns: 13 + * - /vue/.well-known/skills/index.json → skills index 14 + * - /vue/.well-known/skills/my-skill/SKILL.md → raw SKILL.md 15 + * - /@scope/pkg/.well-known/skills/... → scoped packages 16 + */ 17 + export default defineCachedEventHandler( 18 + async event => { 19 + const path = getRouterParam(event, 'path') || '' 20 + 21 + const match = path.match(/^(.+?)\/\.well-known\/skills\/(.*)$/) 22 + if (!match) { 23 + // Not a well-known skills request, return 404 to let other handlers deal with it 24 + throw createError({ statusCode: 404, message: 'Not found' }) 25 + } 26 + 27 + const [, pkgPath, skillsPath] = match 28 + const packageName = pkgPath! 29 + 30 + try { 31 + const validated = v.parse(PackageRouteParamsSchema, { packageName, version: undefined }) 32 + 33 + // Always resolve to latest for well-known endpoint 34 + const packument = await fetchNpmPackage(validated.packageName) 35 + const version = packument['dist-tags']?.latest 36 + if (!version) { 37 + throw createError({ statusCode: 404, message: 'No latest version found' }) 38 + } 39 + 40 + if (skillsPath === 'index.json' || skillsPath === '') { 41 + return await handleWellKnownIndex(event, validated.packageName, version) 42 + } 43 + 44 + const parts = skillsPath!.split('/') 45 + const skillName = v.parse(SkillNameSchema, parts[0]) 46 + const fileName = parts.slice(1).join('/') 47 + 48 + if (fileName === 'SKILL.md' || fileName === '') { 49 + return await handleWellKnownSkillMd(event, validated.packageName, version, skillName) 50 + } 51 + 52 + throw createError({ statusCode: 404, message: 'File not found' }) 53 + } catch (error) { 54 + if (error && typeof error === 'object' && 'statusCode' in error) throw error 55 + throw createError({ statusCode: 500, message: 'Failed to fetch skills' }) 56 + } 57 + }, 58 + { 59 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 60 + swr: true, 61 + getKey: event => { 62 + const path = getRouterParam(event, 'path') ?? '' 63 + return `well-known-skills:v${CACHE_VERSION}:${path.replace(/\/+$/, '').trim()}` 64 + }, 65 + }, 66 + ) 67 + 68 + async function handleWellKnownIndex(event: H3Event, packageName: string, version: string) { 69 + const fileTree = await getPackageFileTree(packageName, version) 70 + const skillDirs = findSkillDirs(fileTree.tree) 71 + 72 + if (skillDirs.length === 0) { 73 + return { skills: [] } 74 + } 75 + 76 + const skillNames = skillDirs.map(s => s.name) 77 + const skills = await fetchSkillsListForWellKnown(packageName, version, skillNames) 78 + 79 + setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}`) 80 + setHeader(event, 'Content-Type', 'application/json') 81 + 82 + return { skills } 83 + } 84 + 85 + async function handleWellKnownSkillMd( 86 + event: H3Event, 87 + packageName: string, 88 + version: string, 89 + skillName: string, 90 + ) { 91 + try { 92 + const content = await fetchSkillFile(packageName, version, `skills/${skillName}/SKILL.md`) 93 + 94 + setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE_ONE_YEAR}, immutable`) 95 + setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') 96 + 97 + return content 98 + } catch (error) { 99 + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { 100 + throw createError({ statusCode: 404, message: 'SKILL.md not found' }) 101 + } 102 + throw error 103 + } 104 + }
+150
server/routes/skills/[...pkg].get.ts
··· 1 + import type { H3Event } from 'h3' 2 + import * as v from 'valibot' 3 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 4 + import { SkillNameSchema } from '#shared/schemas/skills' 5 + import type { SkillsListResponse, SkillContentResponse } from '#shared/types' 6 + import { 7 + CACHE_MAX_AGE_ONE_HOUR, 8 + CACHE_MAX_AGE_ONE_YEAR, 9 + ERROR_SKILLS_FETCH_FAILED, 10 + ERROR_SKILL_NOT_FOUND, 11 + ERROR_SKILL_FILE_NOT_FOUND, 12 + } from '#shared/utils/constants' 13 + import { parsePackageParam } from '#shared/utils/parse-package-param' 14 + 15 + const CACHE_VERSION = 1 16 + 17 + /** 18 + * Skills discovery and content endpoint. 19 + * 20 + * URL patterns: 21 + * - /skills/vue/v/3.4.0 → discovery (list skills) 22 + * - /skills/vue/v/3.4.0/my-skill → skill content (SKILL.md parsed) 23 + * - /skills/vue/v/3.4.0/my-skill/refs/guide.md → supporting file (raw) 24 + * - /skills/@scope/pkg/v/1.0.0 → scoped package 25 + */ 26 + export default defineCachedEventHandler( 27 + async event => { 28 + const pkgParam = getRouterParam(event, 'pkg') 29 + if (!pkgParam) { 30 + throw createError({ statusCode: 404, message: 'Package name is required' }) 31 + } 32 + 33 + const { packageName, version: rawVersion, rest } = parsePackageParam(pkgParam) 34 + 35 + try { 36 + const validated = v.parse(PackageRouteParamsSchema, { packageName, version: rawVersion }) 37 + 38 + let version = validated.version 39 + let isVersioned = !!version 40 + if (!version) { 41 + const packument = await fetchNpmPackage(validated.packageName) 42 + version = packument['dist-tags']?.latest 43 + if (!version) { 44 + throw createError({ statusCode: 404, message: 'No latest version found' }) 45 + } 46 + } 47 + 48 + // Set cache headers: 1 year for versioned, 1 hour for latest 49 + if (isVersioned) { 50 + setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE_ONE_YEAR}, immutable`) 51 + } 52 + 53 + if (rest.length === 0) { 54 + return await handleDiscovery(validated.packageName, version) 55 + } 56 + 57 + const skillName = v.parse(SkillNameSchema, rest[0]) 58 + 59 + if (rest.length === 1) { 60 + return await handleSkillContent(validated.packageName, version, skillName) 61 + } 62 + 63 + const filePath = rest.slice(1).join('/') 64 + return await handleSkillFile(event, validated.packageName, version, skillName, filePath) 65 + } catch (error) { 66 + handleApiError(error, { statusCode: 502, message: ERROR_SKILLS_FETCH_FAILED }) 67 + } 68 + }, 69 + { 70 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 71 + swr: true, 72 + getKey: event => { 73 + const pkg = getRouterParam(event, 'pkg') ?? '' 74 + return `skills:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` 75 + }, 76 + }, 77 + ) 78 + 79 + async function handleDiscovery(packageName: string, version: string): Promise<SkillsListResponse> { 80 + const fileTree = await getPackageFileTree(packageName, version) 81 + const skillDirs = findSkillDirs(fileTree.tree) 82 + 83 + if (skillDirs.length === 0) { 84 + return { package: packageName, version, skills: [] } 85 + } 86 + 87 + const skills = await fetchSkillsList(packageName, version, skillDirs) 88 + return { package: packageName, version, skills } 89 + } 90 + 91 + async function handleSkillContent( 92 + packageName: string, 93 + version: string, 94 + skillName: string, 95 + ): Promise<SkillContentResponse> { 96 + try { 97 + const { frontmatter, content } = await fetchSkillContent(packageName, version, skillName) 98 + return { package: packageName, version, skill: skillName, frontmatter, content } 99 + } catch (error) { 100 + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { 101 + throw createError({ statusCode: 404, message: ERROR_SKILL_NOT_FOUND }) 102 + } 103 + throw error 104 + } 105 + } 106 + 107 + async function handleSkillFile( 108 + event: H3Event, 109 + packageName: string, 110 + version: string, 111 + skillName: string, 112 + filePath: string, 113 + ): Promise<string> { 114 + // Validate file path to prevent directory traversal 115 + if (filePath.includes('..') || filePath.startsWith('/')) { 116 + throw createError({ statusCode: 400, message: 'Invalid file path' }) 117 + } 118 + 119 + // Only allow files within skill subdirectories (scripts/, references/, assets/) 120 + const allowedPrefixes = ['scripts/', 'references/', 'assets/', 'refs/'] 121 + if (!allowedPrefixes.some(p => filePath.startsWith(p))) { 122 + throw createError({ 123 + statusCode: 400, 124 + message: 'File must be in scripts/, references/, or assets/ subdirectory', 125 + }) 126 + } 127 + 128 + try { 129 + const content = await fetchSkillFile(packageName, version, `skills/${skillName}/${filePath}`) 130 + 131 + const ext = filePath.split('.').pop()?.toLowerCase() || '' 132 + const contentTypes: Record<string, string> = { 133 + md: 'text/markdown', 134 + txt: 'text/plain', 135 + json: 'application/json', 136 + js: 'text/javascript', 137 + ts: 'text/typescript', 138 + sh: 'text/x-shellscript', 139 + py: 'text/x-python', 140 + } 141 + setHeader(event, 'Content-Type', contentTypes[ext] || 'text/plain') 142 + 143 + return content 144 + } catch (error) { 145 + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { 146 + throw createError({ statusCode: 404, message: ERROR_SKILL_FILE_NOT_FOUND }) 147 + } 148 + throw error 149 + } 150 + }
+2 -1
server/utils/docs/client.ts
··· 8 8 */ 9 9 10 10 import { doc, type DocNode } from '@deno/doc' 11 + import { $fetch } from 'ofetch' 11 12 import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc' 12 13 13 14 // ============================================================================= ··· 163 164 164 165 try { 165 166 const response = await $fetch.raw(url, { 166 - method: 'HEAD', 167 + method: 'HEAD' as 'GET', // Cast to satisfy Nitro's typed $fetch (external URL, any method is fine) 167 168 timeout: FETCH_TIMEOUT_MS, 168 169 }) 169 170 return response.headers.get('x-typescript-types')
+238
server/utils/skills.ts
··· 1 + import type { 2 + PackageFileTree, 3 + SkillFileCounts, 4 + SkillFrontmatter, 5 + SkillListItem, 6 + SkillWarning, 7 + } from '#shared/types' 8 + 9 + const MAX_SKILL_FILE_SIZE = 500 * 1024 10 + 11 + /** 12 + * Parse YAML frontmatter from SKILL.md content. 13 + * Returns { frontmatter, content } where content is the markdown body without frontmatter. 14 + */ 15 + export function parseFrontmatter(raw: string): { frontmatter: SkillFrontmatter; content: string } { 16 + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/) 17 + if (!match) { 18 + throw createError({ statusCode: 400, message: 'Invalid SKILL.md: missing YAML frontmatter' }) 19 + } 20 + 21 + const yamlBlock = match[1]! 22 + const content = match[2]! 23 + 24 + const frontmatter: Record<string, string | Record<string, string>> = {} 25 + let currentKey = '' 26 + let inMetadata = false 27 + const metadata: Record<string, string> = {} 28 + 29 + for (const line of yamlBlock.split('\n')) { 30 + const trimmed = line.trim() 31 + if (!trimmed || trimmed.startsWith('#')) continue 32 + 33 + if (line.startsWith(' ') && inMetadata) { 34 + const [key, ...valueParts] = trimmed.split(':') 35 + if (key && valueParts.length) { 36 + metadata[key.trim()] = valueParts 37 + .join(':') 38 + .trim() 39 + .replace(/^["']|["']$/g, '') 40 + } 41 + } else { 42 + const colonIndex = line.indexOf(':') 43 + if (colonIndex !== -1) { 44 + currentKey = line.slice(0, colonIndex).trim() 45 + const value = line.slice(colonIndex + 1).trim() 46 + inMetadata = currentKey === 'metadata' && !value 47 + if (!inMetadata && value) { 48 + frontmatter[currentKey] = value.replace(/^["']|["']$/g, '') 49 + } 50 + } 51 + } 52 + } 53 + 54 + if (Object.keys(metadata).length > 0) { 55 + frontmatter.metadata = metadata 56 + } 57 + 58 + if (!frontmatter.name || !frontmatter.description) { 59 + throw createError({ 60 + statusCode: 400, 61 + message: 'Invalid SKILL.md: missing required name or description', 62 + }) 63 + } 64 + 65 + return { frontmatter: frontmatter as unknown as SkillFrontmatter, content } 66 + } 67 + 68 + export interface SkillDirInfo { 69 + name: string 70 + children: PackageFileTree[] 71 + } 72 + 73 + /** 74 + * Find skill directories in a package file tree. 75 + * Returns skill names and their children for file counting. 76 + * @public 77 + */ 78 + export function findSkillDirs(tree: PackageFileTree[]): SkillDirInfo[] { 79 + const skillsDir = tree.find(node => node.type === 'directory' && node.name === 'skills') 80 + if (!skillsDir?.children) return [] 81 + 82 + return skillsDir.children 83 + .filter( 84 + child => 85 + child.type === 'directory' && 86 + child.children?.some(f => f.type === 'file' && f.name === 'SKILL.md'), 87 + ) 88 + .map(child => ({ name: child.name, children: child.children || [] })) 89 + } 90 + 91 + /** 92 + * Count files in skill subdirectories (scripts, references, assets). 93 + */ 94 + export function countSkillFiles(children: PackageFileTree[]): SkillFileCounts | undefined { 95 + const counts: SkillFileCounts = {} 96 + const countFilesRecursive = (nodes: PackageFileTree[]): number => 97 + nodes.reduce( 98 + (acc, n) => acc + (n.type === 'file' ? 1 : countFilesRecursive(n.children || [])), 99 + 0, 100 + ) 101 + 102 + for (const child of children) { 103 + if (child.type !== 'directory') continue 104 + const name = child.name.toLowerCase() 105 + const count = countFilesRecursive(child.children || []) 106 + if (count === 0) continue 107 + if (name === 'scripts') counts.scripts = count 108 + else if (name === 'references' || name === 'refs') 109 + counts.references = (counts.references || 0) + count 110 + else if (name === 'assets') counts.assets = count 111 + } 112 + return Object.keys(counts).length ? counts : undefined 113 + } 114 + 115 + /** 116 + * Fetch file content from jsDelivr CDN with size limit. 117 + */ 118 + export async function fetchSkillFile( 119 + packageName: string, 120 + version: string, 121 + filePath: string, 122 + ): Promise<string> { 123 + const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}` 124 + const response = await fetch(url) 125 + 126 + if (!response.ok) { 127 + if (response.status === 404) { 128 + throw createError({ statusCode: 404, message: 'File not found' }) 129 + } 130 + throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' }) 131 + } 132 + 133 + const contentLength = response.headers.get('content-length') 134 + if (contentLength && parseInt(contentLength, 10) > MAX_SKILL_FILE_SIZE) { 135 + throw createError({ 136 + statusCode: 413, 137 + message: `File too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_SKILL_FILE_SIZE / 1024}KB.`, 138 + }) 139 + } 140 + 141 + const content = await response.text() 142 + 143 + if (content.length > MAX_SKILL_FILE_SIZE) { 144 + throw createError({ 145 + statusCode: 413, 146 + message: `File too large (${(content.length / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_SKILL_FILE_SIZE / 1024}KB.`, 147 + }) 148 + } 149 + 150 + return content 151 + } 152 + 153 + /** 154 + * Fetch and parse SKILL.md content for a skill. 155 + */ 156 + export async function fetchSkillContent( 157 + packageName: string, 158 + version: string, 159 + skillName: string, 160 + ): Promise<{ frontmatter: SkillFrontmatter; content: string }> { 161 + const raw = await fetchSkillFile(packageName, version, `skills/${skillName}/SKILL.md`) 162 + return parseFrontmatter(raw) 163 + } 164 + 165 + /** 166 + * Validate skill frontmatter and return warnings. 167 + */ 168 + export function validateSkill(frontmatter: SkillFrontmatter): SkillWarning[] { 169 + const warnings: SkillWarning[] = [] 170 + if (!frontmatter.license) { 171 + warnings.push({ type: 'warning', message: 'No license specified' }) 172 + } 173 + if (!frontmatter.compatibility) { 174 + warnings.push({ type: 'warning', message: 'No compatibility info' }) 175 + } 176 + return warnings 177 + } 178 + 179 + /** 180 + * Fetch skill list with frontmatter for discovery endpoint. 181 + * @public 182 + */ 183 + export async function fetchSkillsList( 184 + packageName: string, 185 + version: string, 186 + skillDirs: SkillDirInfo[], 187 + ): Promise<SkillListItem[]> { 188 + const skills = await Promise.all( 189 + skillDirs.map(async ({ name: dirName, children }) => { 190 + try { 191 + const { frontmatter } = await fetchSkillContent(packageName, version, dirName) 192 + const warnings = validateSkill(frontmatter) 193 + const fileCounts = countSkillFiles(children) 194 + const item: SkillListItem = { 195 + name: frontmatter.name, 196 + description: frontmatter.description, 197 + dirName, 198 + license: frontmatter.license, 199 + compatibility: frontmatter.compatibility, 200 + warnings: warnings.length > 0 ? warnings : undefined, 201 + fileCounts, 202 + } 203 + return item 204 + } catch { 205 + return null 206 + } 207 + }), 208 + ) 209 + return skills.filter((s): s is SkillListItem => s !== null) 210 + } 211 + 212 + export interface WellKnownSkillItem { 213 + name: string 214 + description: string 215 + files: string[] 216 + } 217 + 218 + /** 219 + * Fetch skill list for well-known index.json format (CLI compatibility). 220 + * @public 221 + */ 222 + export async function fetchSkillsListForWellKnown( 223 + packageName: string, 224 + version: string, 225 + skillNames: string[], 226 + ): Promise<WellKnownSkillItem[]> { 227 + const skills = await Promise.all( 228 + skillNames.map(async dirName => { 229 + try { 230 + const { frontmatter } = await fetchSkillContent(packageName, version, dirName) 231 + return { name: dirName, description: frontmatter.description, files: ['SKILL.md'] } 232 + } catch { 233 + return null 234 + } 235 + }), 236 + ) 237 + return skills.filter((s): s is WellKnownSkillItem => s !== null) 238 + }
+9
shared/schemas/skills.ts
··· 1 + import * as v from 'valibot' 2 + 3 + export const SkillNameSchema = v.pipe( 4 + v.string(), 5 + v.minLength(1), 6 + v.maxLength(64), 7 + v.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Invalid skill name'), 8 + v.check(s => !s.includes('--'), 'No consecutive hyphens'), 9 + )
+1
shared/types/index.ts
··· 7 7 export * from './deno-doc' 8 8 export * from './i18n-status' 9 9 export * from './comparison' 10 + export * from './skills'
+42
shared/types/skills.ts
··· 1 + export interface SkillFrontmatter { 2 + name: string 3 + description: string 4 + license?: string 5 + compatibility?: string 6 + metadata?: Record<string, string> 7 + } 8 + 9 + export interface SkillWarning { 10 + type: 'error' | 'warning' 11 + message: string 12 + } 13 + 14 + export interface SkillFileCounts { 15 + scripts?: number 16 + references?: number 17 + assets?: number 18 + } 19 + 20 + export interface SkillListItem { 21 + name: string 22 + description: string 23 + dirName: string 24 + license?: string 25 + compatibility?: string 26 + warnings?: SkillWarning[] 27 + fileCounts?: SkillFileCounts 28 + } 29 + 30 + export interface SkillsListResponse { 31 + package: string 32 + version: string 33 + skills: SkillListItem[] 34 + } 35 + 36 + export interface SkillContentResponse { 37 + package: string 38 + version: string 39 + skill: string 40 + frontmatter: SkillFrontmatter 41 + content: string 42 + }
+3
shared/utils/constants.ts
··· 17 17 export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' 18 18 export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' 19 19 export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' 20 + export const ERROR_SKILLS_FETCH_FAILED = 'Failed to fetch skills.' 21 + export const ERROR_SKILL_NOT_FOUND = 'Skill not found.' 22 + export const ERROR_SKILL_FILE_NOT_FOUND = 'Skill file not found.' 20 23 21 24 // microcosm services 22 25 export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
+5 -2
uno.config.ts
··· 9 9 import { presetRtl } from './uno-preset-rtl' 10 10 11 11 const customIcons = { 12 - tangled: 12 + 'agent-skills': 13 + '<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 0.5L29.4234 8.25V23.75L16 31.5L2.57661 23.75V8.25L16 0.5Z" fill="currentColor"/><path d="M16 6L24.6603 11V21L16 26L7.33975 21V11L16 6Z" fill="none" stroke="currentColor" stroke-width="3"/></svg>', 14 + 'tangled': 13 15 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path fill="currentColor" d="M16.346 24.11c-.785-.007-1.384-.235-2.034-.632-.932-.49-1.643-1.314-2.152-2.222-.808 1.003-1.888 1.611-3.097 1.955-.515.15-1.416.301-2.911-.24-2.154-.724-3.723-2.965-3.545-5.25-.033-.946.313-1.875.802-2.674-1.305-.7-2.37-1.876-2.777-3.318-.248-.79-.237-1.64-.146-2.452.327-1.916 1.764-3.582 3.615-4.182.738-1.683 2.35-2.938 4.176-3.193a5.54 5.54 0 0 1 3.528.7C13.351.89 16.043.383 18.1 1.436c1.568.75 2.69 2.312 2.962 4.015 1.492.598 2.749 1.817 3.242 3.365.33.958.34 2.013.127 2.997-.382 1.536-1.465 2.842-2.868 3.557.003.273.901 2.243.751 3.73-.033 1.858-1.211 3.62-2.846 4.475-.954.557-2.085.546-3.12.535m-4.47-5.35c1.322-.148 2.19-1.3 2.862-2.339.319-.473.562-1.002.803-1.506.314.287.58.828 1.075.957.522.163 1.133.03 1.453-.443.611-1.14.31-2.517-.046-3.699-.22-.679-.507-1.375-1.054-1.856.116-.823-.372-1.66-1.065-2.09-.592.47-1.491.468-2.061-.037-1.093 1.115-2.095 1.078-3.063.195-.217-.199-.632 1.212-2.088.413-.837.7-1.485 1.375-2.06 2.346-.559 1.046-1.143 1.976-1.194 3.113-.024.664.495 1.36 1.198 1.306.703.063 1.182-.63 1.714-.917.08.928.169 1.924.482 2.829.36 1.171 1.627 1.916 2.825 1.745zm.687-3.498c-.644-.394-.334-1.25-.36-1.871.064-.75.115-1.538.453-2.221.356-.487 1.226-.3 1.265.326-.026.628-.314 1.254-.28 1.905-.075.544.054 1.155-.186 1.653-.198.275-.6.355-.892.208m-2.81-.358c-.605-.329-.413-1.156-.508-1.73.08-.666.014-1.51.571-1.978.545-.38 1.287.271 1.03.869-.276.755-.096 1.58-.09 2.346a.712.712 0 0 1-1.002.493"/></svg>', 14 - vlt: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.9991 5.03666C7.9991 5.46387 7.93211 5.87545 7.80808 6.26145C7.45933 7.34674 7.1975 8.58253 7.7669 9.57009L10.389 14.1177C10.7072 14.6697 11.3617 14.9108 11.9989 14.9108V14.9108V14.9108C12.6352 14.9108 13.2886 14.6699 13.6064 14.1187L16.2301 9.5682C16.7993 8.58097 16.5379 7.34565 16.1895 6.26064C16.0656 5.87488 15.9987 5.46358 15.9987 5.03666C15.9987 2.82777 17.7894 1.03711 19.9983 1.03711C22.2071 1.03711 23.9978 2.82777 23.9978 5.03666C23.9978 7.24555 22.2071 9.03621 19.9983 9.03621V9.03621C19.3609 9.03621 18.7062 9.27733 18.3878 9.82951L15.7661 14.3766C15.1967 15.3642 15.4586 16.6001 15.8074 17.6854C15.9314 18.0715 15.9984 18.4831 15.9984 18.9104C15.9984 21.1193 14.2078 22.9099 11.9989 22.9099C9.79001 22.9099 7.99935 21.1193 7.99935 18.9104C7.99935 18.4834 8.06626 18.072 8.19016 17.6862C8.53863 16.6012 8.80017 15.3657 8.23092 14.3785L5.60752 9.8285C5.28961 9.27712 4.63601 9.03621 3.99955 9.03621V9.03621C1.79066 9.03621 0 7.24555 0 5.03666C0 2.82777 1.79066 1.03711 3.99955 1.03711C6.20844 1.03711 7.9991 2.82777 7.9991 5.03666Z" fill="currentColor"></path></svg>', 16 + 'vlt': 17 + '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.9991 5.03666C7.9991 5.46387 7.93211 5.87545 7.80808 6.26145C7.45933 7.34674 7.1975 8.58253 7.7669 9.57009L10.389 14.1177C10.7072 14.6697 11.3617 14.9108 11.9989 14.9108V14.9108V14.9108C12.6352 14.9108 13.2886 14.6699 13.6064 14.1187L16.2301 9.5682C16.7993 8.58097 16.5379 7.34565 16.1895 6.26064C16.0656 5.87488 15.9987 5.46358 15.9987 5.03666C15.9987 2.82777 17.7894 1.03711 19.9983 1.03711C22.2071 1.03711 23.9978 2.82777 23.9978 5.03666C23.9978 7.24555 22.2071 9.03621 19.9983 9.03621V9.03621C19.3609 9.03621 18.7062 9.27733 18.3878 9.82951L15.7661 14.3766C15.1967 15.3642 15.4586 16.6001 15.8074 17.6854C15.9314 18.0715 15.9984 18.4831 15.9984 18.9104C15.9984 21.1193 14.2078 22.9099 11.9989 22.9099C9.79001 22.9099 7.99935 21.1193 7.99935 18.9104C7.99935 18.4834 8.06626 18.072 8.19016 17.6862C8.53863 16.6012 8.80017 15.3657 8.23092 14.3785L5.60752 9.8285C5.28961 9.27712 4.63601 9.03621 3.99955 9.03621V9.03621C1.79066 9.03621 0 7.24555 0 5.03666C0 2.82777 1.79066 1.03711 3.99955 1.03711C6.20844 1.03711 7.9991 2.82777 7.9991 5.03666Z" fill="currentColor"></path></svg>', 15 18 } 16 19 17 20 export default defineConfig({