diff --git a/layers/registry/app/components/_SkillCommandPanel.vue b/layers/registry/app/components/_SkillCommandPanel.vue
index 387a4363..9d09d9cc 100644
--- a/layers/registry/app/components/_SkillCommandPanel.vue
+++ b/layers/registry/app/components/_SkillCommandPanel.vue
@@ -1,7 +1,10 @@
+
+
+
+
+
+
+
Works with your agent
+
+
+
+
+
+ Run once off
+
+
+ This session only. Nothing lands on disk. Nothing to clean up.
+
+
+
+ {{ SKILL_RUN_PROMPT_LEAD }} {{ runUrl }}
+
+
+
+
+
+
+ Install as a Skill
+
+
+
+
+
+
+
+ The files land in your project. The lockfile records them.
+
+
+
+
+
+
+
+
+ Download the ZIP. {{ uploadSteps[installTarget] }}
+
+
+
+ {{ zipState.message }}
+
+
+
+
+
+
+ {{ copyError }}
+
+
+
@@ -50,7 +223,7 @@ const copyErrorId = useId()
v-for="item in modes"
:key="item.value"
type="button"
- class="-mb-px min-h-11 border-b font-mono text-xs transition-colors"
+ class="-mb-px min-h-11 min-w-11 border-b font-mono text-xs transition-colors"
:class="mode === item.value
? 'border-primary text-default'
: 'border-transparent text-muted hover:text-default'"
@@ -83,6 +256,13 @@ const copyErrorId = useId()
/>
+
+ This session only. Nothing lands on disk.
+
+
{{ copyError }}
-
-
+
+
diff --git a/layers/registry/app/components/_SkillReceiptsPanel.vue b/layers/registry/app/components/_SkillReceiptsPanel.vue
index 18a958f0..32c24ab1 100644
--- a/layers/registry/app/components/_SkillReceiptsPanel.vue
+++ b/layers/registry/app/components/_SkillReceiptsPanel.vue
@@ -118,7 +118,7 @@ const hasStatus = computed(() => Boolean(maturity || verifiedSummary || modified
Last checked against GitHub {{ lastSyncedAgo }}.
diff --git a/layers/registry/app/components/_SkillStarTrend.vue b/layers/registry/app/components/_SkillStarTrend.vue
new file mode 100644
index 00000000..0fb73ff6
--- /dev/null
+++ b/layers/registry/app/components/_SkillStarTrend.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+ {{ focusPoint.value.toLocaleString() }}
+ {{ hovered === null ? `stars ${gainedLabel}` : `stars ${dateFormatter.format(focusPoint.at * 1000)}` }}
+
+
+
+
+
+
+
diff --git a/layers/registry/app/components/_SkillThirdPartyChecks.vue b/layers/registry/app/components/_SkillThirdPartyChecks.vue
index 20c77f57..9bca3d99 100644
--- a/layers/registry/app/components/_SkillThirdPartyChecks.vue
+++ b/layers/registry/app/components/_SkillThirdPartyChecks.vue
@@ -10,6 +10,8 @@ const { audits = [] } = defineProps<{
audits?: SkillAudit[]
}>()
+const open = defineModel('open', { default: false })
+
const AUDIT_META: Record = {
pass: { icon: 'i-lucide-shield-check', klass: 'text-success' },
warn: { icon: 'i-lucide-shield-alert', klass: 'text-warning' },
@@ -51,18 +53,17 @@ const AUDIT_TONE_CLASS = {
>
Third-party checks
-
- Reports from outside skilld.
-
-import { githubAvatarProxyUrl } from '#shared/image-proxy'
import { normalizeSkillAssetFilePath } from '#shared/skill-asset-path'
+// The Skill page with one file open in the viewer. SkillDetail sets the
+// title, noindex, and canonical for it.
const route = useRoute()
const owner = computed(() => String(route.params.owner ?? ''))
const repo = computed(() => String(route.params.repo ?? ''))
@@ -15,192 +16,13 @@ const file = computed(() => {
filePath: Array.isArray(raw) ? raw.join('/') : String(raw ?? ''),
})
})
-
-const slug = computed(() => `${owner.value}/${repo.value}/${name.value}/${file.value}`)
-
-const { data, status, error } = useFetch(
- () => `/api/skill-asset/${slug.value}`,
- { watch: [slug] },
-) as ReturnType>
-
-const fileName = computed(() => {
- const parts = file.value.split('/')
- return parts[parts.length - 1] ?? file.value
-})
-
-const skillPath = computed(() => repoSkillPath(owner.value, repo.value, name.value))
-
-const githubUrl = computed(() => {
- if (!data.value?.skillPath)
- return null
- const dir = data.value.skillPath.replace(/\/SKILL\.md$/, '')
- return `https://github.com/${owner.value}/${repo.value}/blob/${data.value.branch}/${dir}/${file.value}`
-})
-
-const sourceHtml = ref(null)
-const sourceError = ref(null)
-
-async function renderSource(raw: string) {
- sourceError.value = null
- try {
- const ext = file.value.toLowerCase().split('.').pop() ?? ''
- const { highlightToHtml } = await import('#shared/highlight')
- // null means the extension isn't one we ship a grammar for; render the
- // source unhighlighted rather than failing.
- sourceHtml.value = highlightToHtml(raw, ext)
- }
- catch (err) {
- sourceError.value = err instanceof Error ? err.message : 'Failed to render source'
- }
-}
-
-watch(
- () => data.value?.raw,
- (raw) => {
- if (!import.meta.client || !raw || !data.value)
- return
- if (data.value.type === 'markdown')
- return
- sourceHtml.value = null
- renderSource(raw)
- },
- { immediate: true },
-)
-
-const pageTitle = computed(() => `${fileName.value} · ${name.value}`)
-useSeoMeta({
- title: () => pageTitle.value,
- description: () => `Reference file from the ${name.value} skill by ${owner.value}.`,
- robots: 'noindex,follow',
-})
-useHead(computed(() => ({
- link: githubUrl.value ? [{ rel: 'canonical', href: githubUrl.value }] : [],
-})))
-
-
-
- Back to {{ name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ error?.statusCode === 404 ? "Couldn't find this reference file." : "Couldn't load this reference file." }}
-
-
-
-
-
-
-
-
-
Couldn't render this file.
-
- {{ sourceError }}
-
-
- {{ data.raw }}
-
-
+
diff --git a/layers/registry/app/utils/skill-context-cost.ts b/layers/registry/app/utils/skill-context-cost.ts
new file mode 100644
index 00000000..41918186
--- /dev/null
+++ b/layers/registry/app/utils/skill-context-cost.ts
@@ -0,0 +1,98 @@
+interface SkillFile {
+ path: string
+ size: number
+ type: 'markdown' | 'code' | 'image' | 'data' | 'other'
+}
+
+/**
+ * What a Skill costs an Agent's context at each loading stage of the Agent
+ * Skills spec. The name and description load in every session, the SKILL.md
+ * body loads when the Agent uses the Skill, and the other Markdown and data
+ * files load only when the body sends the Agent to them.
+ */
+export interface SkillContextCost {
+ fileCount: number
+ totalBytes: number
+ tokens: {
+ metadata: number
+ instructions: number
+ resources: number
+ }
+ /** Markdown and data files outside SKILL.md, the ones behind `resources`. */
+ resourceFileCount: number
+}
+
+/** How one file in the Skill folder reaches the Agent's context. */
+export type SkillFileContext
+ = | { _tag: 'resource', tokens: number }
+ | { _tag: 'script' }
+ | { _tag: 'unread' }
+
+// Roughly four bytes per token for English prose. The UI prints every count
+// with "≈", so this only has to hold the order of magnitude.
+const BYTES_PER_TOKEN = 4
+// The Agent reads Markdown and data into context. It runs scripts through a
+// shell, so only their output lands in context. The API types any text file as
+// `code`, LICENSE.txt included, so scripts go by extension.
+const READ_TYPES = new Set(['markdown', 'data'])
+const SCRIPT_PATH = /\.(?:py|sh|bash|zsh|js|mjs|cjs|ts|rb|go|ps1|php|pl)$/i
+const LICENSE_PATH = /(?:^|\/)licen[cs]e(?:\.\w+)?$/i
+const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/
+
+const encoder = new TextEncoder()
+
+function byteLength(text: string): number {
+ return encoder.encode(text).byteLength
+}
+
+function tokensFor(bytes: number): number {
+ return Math.ceil(bytes / BYTES_PER_TOKEN)
+}
+
+export function resolveSkillContextCost(input: {
+ raw: string | null
+ name: string
+ description: string | null
+ files: SkillFile[]
+}): SkillContextCost {
+ const skillMdBytes = input.raw ? byteLength(input.raw) : 0
+ const body = input.raw?.replace(FRONTMATTER, '') ?? ''
+ const others = input.files.filter(file => file.path !== 'SKILL.md')
+ const resources = others.filter(file => resolveSkillFileContext(file)._tag === 'resource')
+ const resourceBytes = resources.reduce((sum, file) => sum + file.size, 0)
+ return {
+ fileCount: others.length + 1,
+ totalBytes: skillMdBytes + others.reduce((sum, file) => sum + file.size, 0),
+ tokens: {
+ metadata: tokensFor(byteLength(`${input.name}\n${input.description ?? ''}`)),
+ instructions: tokensFor(byteLength(body.trim())),
+ resources: tokensFor(resourceBytes),
+ },
+ resourceFileCount: resources.length,
+ }
+}
+
+export function resolveSkillFileContext(file: SkillFile): SkillFileContext {
+ if (LICENSE_PATH.test(file.path))
+ return { _tag: 'unread' }
+ if (READ_TYPES.has(file.type))
+ return { _tag: 'resource', tokens: tokensFor(file.size) }
+ if (SCRIPT_PATH.test(file.path))
+ return { _tag: 'script' }
+ return { _tag: 'unread' }
+}
+
+export function formatTokenCount(tokens: number): string {
+ if (tokens < 1000)
+ return `≈${tokens}`
+ const thousands = tokens / 1000
+ return `≈${thousands < 10 ? thousands.toFixed(1).replace(/\.0$/, '') : Math.round(thousands)}k`
+}
+
+export function formatByteSize(bytes: number): string {
+ if (bytes < 1024)
+ return `${bytes} B`
+ if (bytes < 1024 * 1024)
+ return `${(bytes / 1024).toFixed(1).replace(/\.0$/, '')} KB`
+ return `${(bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
+}
diff --git a/layers/registry/app/utils/skill-file-tree.ts b/layers/registry/app/utils/skill-file-tree.ts
index 968f5d36..5264651a 100644
--- a/layers/registry/app/utils/skill-file-tree.ts
+++ b/layers/registry/app/utils/skill-file-tree.ts
@@ -4,18 +4,63 @@ interface SkillAsset {
type: 'markdown' | 'code' | 'image' | 'data' | 'other'
}
-export interface SkillFileTreeNode {
- kind: 'file' | 'dir'
- path: string
- name: string
- children?: SkillFileTreeNode[]
- asset?: SkillAsset
-}
+export type SkillFileTreeNode
+ = | { kind: 'dir', path: string, name: string, children: SkillFileTreeNode[], initiallyOpen: boolean }
+ | { kind: 'file', path: string, name: string, asset: SkillAsset }
+// A lone folder this small opens on load. More folders, or a bigger one, start
+// closed, so SKILL.md stays near the top of the tree.
const AUTO_EXPAND_MAX_CHILDREN = 5
+const LICENSE_NAME = /^licen[cs]e(?:\.\w+)?$/i
+
+function rank(node: SkillFileTreeNode): number {
+ if (node.kind === 'dir')
+ return 0
+ if (node.path === 'SKILL.md')
+ return 1
+ return LICENSE_NAME.test(node.name) ? 3 : 2
+}
+
+function sortNodes(nodes: SkillFileTreeNode[]): SkillFileTreeNode[] {
+ return nodes
+ .map(node => node.kind === 'dir' ? { ...node, children: sortNodes(node.children) } : node)
+ .sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
+}
+
+function withInitialOpen(nodes: SkillFileTreeNode[], parentOpen: boolean): SkillFileTreeNode[] {
+ const folders = nodes.filter(node => node.kind === 'dir')
+ return nodes.map((node) => {
+ if (node.kind !== 'dir')
+ return node
+ const initiallyOpen = parentOpen && folders.length === 1 && node.children.length <= AUTO_EXPAND_MAX_CHILDREN
+ return { ...node, initiallyOpen, children: withInitialOpen(node.children, initiallyOpen) }
+ })
+}
-export function shouldAutoExpandFolder(node: SkillFileTreeNode): boolean {
- return node.kind === 'dir' && (node.children?.length ?? 0) <= AUTO_EXPAND_MAX_CHILDREN
+/**
+ * The Skill folder as a tree: folders first, then SKILL.md, then the other
+ * files, with LICENSE last. The API never lists SKILL.md, so it is added here.
+ */
+export function buildSkillFileTree(assets: SkillAsset[], skillMdSize: number): SkillFileTreeNode[] {
+ const root: SkillFileTreeNode[] = []
+ for (const asset of [{ path: 'SKILL.md', size: skillMdSize, type: 'markdown' as const }, ...assets]) {
+ const parts = asset.path.split('/').filter(Boolean)
+ let level = root
+ parts.forEach((segment, index) => {
+ const path = parts.slice(0, index + 1).join('/')
+ if (index === parts.length - 1) {
+ level.push({ kind: 'file', path, name: segment, asset })
+ return
+ }
+ let dir = level.find((node): node is Extract => node.kind === 'dir' && node.name === segment)
+ if (!dir) {
+ dir = { kind: 'dir', path, name: segment, children: [], initiallyOpen: false }
+ level.push(dir)
+ }
+ level = dir.children
+ })
+ }
+ return withInitialOpen(sortNodes(root), true)
}
const FILENAME_ICONS: Record = {
diff --git a/layers/registry/app/utils/skill-viewer-link.ts b/layers/registry/app/utils/skill-viewer-link.ts
new file mode 100644
index 00000000..d0ffdc2c
--- /dev/null
+++ b/layers/registry/app/utils/skill-viewer-link.ts
@@ -0,0 +1,47 @@
+function resolveRelativePath(baseDir: string, href: string): string | null {
+ let target = href.replace(/^\.\//, '')
+ if (target.startsWith('/')) {
+ target = target.slice(1)
+ return target.includes('..') ? null : target
+ }
+ const segments = baseDir ? baseDir.split('/').filter(Boolean) : []
+ for (const part of target.split('/')) {
+ if (part === '..') {
+ if (!segments.length)
+ return null
+ segments.pop()
+ }
+ else if (part && part !== '.') {
+ segments.push(part)
+ }
+ }
+ return segments.join('/')
+}
+
+/**
+ * The file a link inside the rendered viewer opens, as a path inside the Skill
+ * folder. Null means the link leaves the viewer and the browser follows it.
+ */
+export function resolveViewerLink(input: {
+ href: string
+ /** Path of the open document inside the Skill folder; empty for SKILL.md. */
+ activeDocPath: string
+ /** `/gh////-/`. The server rewrites relative links to it. */
+ fileRoutePrefix: string
+}): string | null {
+ const { href, activeDocPath, fileRoutePrefix } = input
+ if (!href || href.startsWith('#') || /^[a-z][a-z0-9+.-]*:\/\//i.test(href))
+ return null
+ const cleanHref = href.split('#')[0]?.split('?')[0] ?? ''
+ const lower = cleanHref.toLowerCase()
+ if (!lower.endsWith('.md') && !lower.endsWith('.markdown'))
+ return null
+ if (cleanHref.startsWith(fileRoutePrefix)) {
+ const path = decodeURIComponent(cleanHref.slice(fileRoutePrefix.length))
+ return path.split('/').includes('..') ? null : path
+ }
+ const baseDir = activeDocPath.includes('/')
+ ? activeDocPath.slice(0, activeDocPath.lastIndexOf('/'))
+ : ''
+ return resolveRelativePath(baseDir, cleanHref)
+}
diff --git a/layers/registry/app/utils/skill-zip.ts b/layers/registry/app/utils/skill-zip.ts
new file mode 100644
index 00000000..900d0b76
--- /dev/null
+++ b/layers/registry/app/utils/skill-zip.ts
@@ -0,0 +1,35 @@
+/** Progress of a Skill ZIP the browser is building. */
+export type ZipState
+ = | { _tag: 'idle' }
+ | { _tag: 'building', done: number, total: number }
+ | { _tag: 'error', message: string }
+
+export interface SkillZipEntry {
+ /** Path inside the ZIP. The Skill folder is the root, as Claude and ChatGPT expect. */
+ zipPath: string
+ url: string
+}
+
+/**
+ * Every file of a Skill as a raw GitHub URL pinned to one commit. The browser
+ * fetches them and builds the ZIP, so the bytes match the commit on the page
+ * and skilld serves nothing.
+ */
+export function resolveSkillZipEntries(input: {
+ owner: string
+ repo: string
+ /** Commit SHA, or the branch when no commit is recorded. */
+ ref: string
+ /** Path of SKILL.md inside the repository. */
+ skillPath: string
+ name: string
+ /** Files inside the Skill folder, without SKILL.md. */
+ files: { path: string }[]
+}): SkillZipEntry[] {
+ const dir = input.skillPath.replace(/\/?SKILL\.md$/i, '')
+ const base = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/${input.ref}`
+ return ['SKILL.md', ...input.files.map(file => file.path)].map(path => ({
+ zipPath: `${input.name}/${path}`,
+ url: `${base}/${[dir, path].filter(Boolean).join('/').split('/').map(encodeURIComponent).join('/')}`,
+ }))
+}
diff --git a/package.json b/package.json
index 920d8c5e..dfb2ecee 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"@vueuse/nuxt": "catalog:",
"agents": "catalog:",
"comark": "catalog:",
+ "fflate": "catalog:",
"h3": "catalog:",
"import-in-the-middle": "catalog:",
"marked": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3094b85d..18be4bb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -293,6 +293,9 @@ catalogs:
eslint-plugin-harlanzw:
specifier: ^0.20.0
version: 0.20.0
+ fflate:
+ specifier: ^0.8.3
+ version: 0.8.3
h3:
specifier: 1.15.11
version: 1.15.11
@@ -446,6 +449,9 @@ importers:
comark:
specifier: 'catalog:'
version: 0.7.0(rangi@2.2.0)
+ fflate:
+ specifier: 'catalog:'
+ version: 0.8.3
h3:
specifier: 'catalog:'
version: 1.15.11
@@ -5957,6 +5963,9 @@ packages:
picomatch:
optional: true
+ fflate@0.8.3:
+ resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -14973,6 +14982,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
+ fflate@0.8.3: {}
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 91c411b1..6cb8079a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -218,6 +218,7 @@ catalog:
comark: 0.7.0
eslint: ^10.9.1
eslint-plugin-harlanzw: ^0.20.0
+ fflate: ^0.8.3
h3: 1.15.11
happy-dom: ^20.12.0
import-in-the-middle: ^3.4.0
diff --git a/test/unit/skill-context-cost.test.ts b/test/unit/skill-context-cost.test.ts
new file mode 100644
index 00000000..61c4c65b
--- /dev/null
+++ b/test/unit/skill-context-cost.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { formatByteSize, formatTokenCount, resolveSkillContextCost, resolveSkillFileContext } from '../../layers/registry/app/utils/skill-context-cost'
+
+describe('skill context cost', () => {
+ it('splits tokens by loading stage and keeps scripts and images out of context', () => {
+ const raw = `---\nname: pdf\ndescription: Read PDFs\n---\n\n${'a'.repeat(400)}\n`
+ const cost = resolveSkillContextCost({
+ raw,
+ name: 'pdf',
+ description: 'Read PDFs',
+ files: [
+ { path: 'reference.md', size: 800, type: 'markdown' },
+ { path: 'schema.json', size: 200, type: 'data' },
+ { path: 'scripts/fill.py', size: 4000, type: 'code' },
+ { path: 'logo.png', size: 9000, type: 'image' },
+ ],
+ })
+
+ expect(cost.tokens).toEqual({ metadata: 4, instructions: 100, resources: 250 })
+ expect(cost.fileCount).toBe(5)
+ expect(cost.resourceFileCount).toBe(2)
+ expect(cost.totalBytes).toBe(raw.length + 14000)
+ })
+
+ it('counts a Skill with no source and no files as SKILL.md alone', () => {
+ const cost = resolveSkillContextCost({ raw: null, name: 'x', description: null, files: [] })
+
+ expect(cost).toEqual({ fileCount: 1, totalBytes: 0, tokens: { metadata: 1, instructions: 0, resources: 0 }, resourceFileCount: 0 })
+ })
+
+ it('says how each file reaches context', () => {
+ expect(resolveSkillFileContext({ path: 'reference.md', size: 4000, type: 'markdown' })).toEqual({ _tag: 'resource', tokens: 1000 })
+ expect(resolveSkillFileContext({ path: 'scripts/fill.py', size: 4000, type: 'code' })).toEqual({ _tag: 'script' })
+ expect(resolveSkillFileContext({ path: 'logo.png', size: 9000, type: 'image' })).toEqual({ _tag: 'unread' })
+ expect(resolveSkillFileContext({ path: 'LICENSE.txt', size: 1467, type: 'code' })).toEqual({ _tag: 'unread' })
+ expect(resolveSkillFileContext({ path: 'LICENSE.md', size: 1467, type: 'markdown' })).toEqual({ _tag: 'unread' })
+ })
+
+ it('formats counts for the chips', () => {
+ expect(formatTokenCount(111)).toBe('≈111')
+ expect(formatTokenCount(1900)).toBe('≈1.9k')
+ expect(formatTokenCount(2000)).toBe('≈2k')
+ expect(formatTokenCount(18_400)).toBe('≈18k')
+ expect(formatByteSize(631)).toBe('631 B')
+ expect(formatByteSize(58_675)).toBe('57.3 KB')
+ })
+})
diff --git a/test/unit/skill-detail-howto-schema.test.ts b/test/unit/skill-detail-howto-schema.test.ts
index 18bb6726..3396562e 100644
--- a/test/unit/skill-detail-howto-schema.test.ts
+++ b/test/unit/skill-detail-howto-schema.test.ts
@@ -174,42 +174,35 @@ describe('skillDetail HowTo structured data', () => {
})
describe('skillDetail command choice', () => {
- it('shows one command and switches between run and install', async () => {
+ it('tabs run and install on small screens, and stacks run above install in the rail', async () => {
const wrapper = await mountSuspended(
await import('../../layers/registry/app/components/SkillDetail.vue').then(module => module.default),
{ props: { owner: 'antfu', repo: 'skills', name: 'vite' } },
)
- const panels = await vi.waitFor(() => {
+ const [tabs, stacked] = await vi.waitFor(() => {
const matches = wrapper.findAll('[data-testid="skill-command-panel"]')
expect(matches).toHaveLength(2)
return matches
})
- for (const panel of panels) {
- expect(panel.findAll('.install-command')).toHaveLength(1)
- expect(panel.text()).not.toContain('Check it worked')
- }
-
- const panel = panels[0]!
- expect(panel.get('.install-command').text()).toBe('https://skilld.dev/gh/antfu/skills/vite')
- expect(panel.get('button[aria-pressed="true"]').text()).toBe('Run')
- expect(panel.text()).toContain('Ask your Agent')
- expect(panel.text()).toContain('Use this Skill:')
- expect(panel.get('button[aria-label="Copy Agent prompt"]')).toBeTruthy()
-
- const installTab = panel.findAll('button[aria-pressed]')
- .find(button => button.text() === 'Install')
+ expect(tabs!.findAll('.install-command')).toHaveLength(1)
+ expect(tabs!.get('.install-command').text()).toBe('https://skilld.dev/gh/antfu/skills/vite')
+ expect(tabs!.text()).toContain('Use this Skill:')
+ const installTab = tabs!.findAll('button[aria-pressed]').find(button => button.text() === 'Install')
expect(installTab, 'Install mode is missing').toBeTruthy()
await installTab!.trigger('click')
-
await vi.waitFor(() => {
- expect(panel.get('.install-command').text()).toBe('npx skilld install antfu/skills/vite')
- expect(panel.get('button[aria-label="Copy install command"]')).toBeTruthy()
- expect(panels[1]!.get('button[aria-pressed="true"]').text()).toBe('Install')
- expect(panel.get('button[aria-expanded="false"]').text()).toContain('Check it worked')
+ expect(tabs!.get('.install-command').text()).toBe('npx skilld install antfu/skills/vite')
})
+ expect(stacked!.findAll('.install-command').map(command => command.text())).toEqual([
+ 'https://skilld.dev/gh/antfu/skills/vite',
+ 'npx skilld install antfu/skills/vite',
+ ])
+ expect(stacked!.findAll('button').some(button => button.text() === 'Copy prompt')).toBe(true)
+ expect(stacked!.get('button[aria-label="Copy install command"]')).toBeTruthy()
+
wrapper.unmount()
})
})
@@ -222,7 +215,7 @@ describe('skillDetail badge utility', () => {
)
const badgeUtility = await vi.waitFor(() => {
- const section = wrapper.find('aside section[aria-labelledby="readme-badge-heading"]')
+ const section = wrapper.find('section[aria-labelledby="readme-badge-heading"]')
expect(section.exists()).toBe(true)
return section
})
diff --git a/test/unit/skill-detail-missing-search.test.ts b/test/unit/skill-detail-missing-search.test.ts
index 9cf25bee..8ef9eaa7 100644
--- a/test/unit/skill-detail-missing-search.test.ts
+++ b/test/unit/skill-detail-missing-search.test.ts
@@ -20,6 +20,7 @@ mockNuxtImport('useAsyncData', () => () => ({
status: ref('success'),
error: ref(null),
refresh: vi.fn(),
+ execute: vi.fn(),
}))
vi.stubGlobal('defineOgImage', () => {})
diff --git a/test/unit/skill-file-tree.test.ts b/test/unit/skill-file-tree.test.ts
index 2c998134..597ca482 100644
--- a/test/unit/skill-file-tree.test.ts
+++ b/test/unit/skill-file-tree.test.ts
@@ -1,24 +1,57 @@
+import type { SkillFileTreeNode } from '../../layers/registry/app/utils/skill-file-tree'
import { describe, expect, it } from 'vitest'
-import { shouldAutoExpandFolder } from '../../layers/registry/app/utils/skill-file-tree'
+import { buildSkillFileTree } from '../../layers/registry/app/utils/skill-file-tree'
-function folderWith(count: number) {
- return {
- kind: 'dir' as const,
- path: 'references',
- name: 'references',
- children: Array.from({ length: count }, (_, index) => ({
- kind: 'file' as const,
- path: `references/${index}.md`,
- name: `${index}.md`,
- })),
- }
+function file(path: string, type: 'markdown' | 'code' = 'markdown') {
+ return { path, size: 100, type }
}
-describe('folder auto expansion', () => {
- it.each([
- [5, true],
- [6, false],
- ])('starts a folder with %i entries open: %s', (count, expected) => {
- expect(shouldAutoExpandFolder(folderWith(count))).toBe(expected)
+function outline(nodes: SkillFileTreeNode[]): string[] {
+ return nodes.flatMap(node => node.kind === 'dir'
+ ? [`${node.name}/${node.initiallyOpen ? ' (open)' : ''}`, ...outline(node.children).map(line => ` ${line}`)]
+ : [node.name])
+}
+
+describe('skill file tree', () => {
+ it('puts folders first, then SKILL.md, then files, with LICENSE last', () => {
+ const tree = buildSkillFileTree([
+ file('reference.md'),
+ file('LICENSE.txt', 'code'),
+ file('scripts/fill.py', 'code'),
+ file('agents/grader.md'),
+ file('agents/analyzer.md'),
+ file('forms.md'),
+ ], 7900)
+
+ expect(outline(tree)).toEqual([
+ 'agents/',
+ ' analyzer.md',
+ ' grader.md',
+ 'scripts/',
+ ' fill.py',
+ 'SKILL.md',
+ 'forms.md',
+ 'reference.md',
+ 'LICENSE.txt',
+ ])
+ })
+
+ it('opens a lone small folder and keeps the rest closed', () => {
+ expect(outline(buildSkillFileTree([file('references/a.md'), file('references/b.md')], 0))).toEqual([
+ 'references/ (open)',
+ ' a.md',
+ ' b.md',
+ 'SKILL.md',
+ ])
+ const many = Array.from({ length: 6 }, (_, index) => file(`rules/${index}.md`))
+ expect(outline(buildSkillFileTree(many, 0))[0]).toBe('rules/')
+ expect(outline(buildSkillFileTree([file('a/x.md'), file('b/y.md')], 0))).toEqual(['a/', ' x.md', 'b/', ' y.md', 'SKILL.md'])
+ })
+
+ it('sorts nested folders the same way and carries SKILL.md size', () => {
+ const tree = buildSkillFileTree([file('docs/z.md'), file('docs/api/a.md')], 7900)
+
+ expect(outline(tree)).toEqual(['docs/ (open)', ' api/ (open)', ' a.md', ' z.md', 'SKILL.md'])
+ expect(tree.at(-1)).toMatchObject({ kind: 'file', asset: { path: 'SKILL.md', size: 7900 } })
})
})
diff --git a/test/unit/skill-viewer-link.test.ts b/test/unit/skill-viewer-link.test.ts
new file mode 100644
index 00000000..ae08a37d
--- /dev/null
+++ b/test/unit/skill-viewer-link.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { resolveViewerLink } from '../../layers/registry/app/utils/skill-viewer-link'
+
+const fileRoutePrefix = '/gh/anthropics/skills/mcp-builder/-/'
+
+describe('skill viewer link', () => {
+ it('opens a server-rewritten file route as a path inside the Skill folder', () => {
+ expect(resolveViewerLink({
+ href: '/gh/anthropics/skills/mcp-builder/-/reference/evaluation.md',
+ activeDocPath: '',
+ fileRoutePrefix,
+ })).toBe('reference/evaluation.md')
+ })
+
+ it('resolves a relative link against the open document folder', () => {
+ expect(resolveViewerLink({ href: '../forms.md', activeDocPath: 'reference/intro.md', fileRoutePrefix })).toBe('forms.md')
+ expect(resolveViewerLink({ href: './deep.md#usage', activeDocPath: 'reference/intro.md', fileRoutePrefix })).toBe('reference/deep.md')
+ })
+
+ it('leaves external links, anchors, non-Markdown files, and escapes to the browser', () => {
+ expect(resolveViewerLink({ href: 'https://example.com/a.md', activeDocPath: '', fileRoutePrefix })).toBeNull()
+ expect(resolveViewerLink({ href: '#setup', activeDocPath: '', fileRoutePrefix })).toBeNull()
+ expect(resolveViewerLink({ href: 'scripts/run.py', activeDocPath: '', fileRoutePrefix })).toBeNull()
+ expect(resolveViewerLink({ href: '../../secret.md', activeDocPath: '', fileRoutePrefix })).toBeNull()
+ expect(resolveViewerLink({ href: '/gh/anthropics/skills/mcp-builder/-/../x.md', activeDocPath: '', fileRoutePrefix })).toBeNull()
+ })
+})
diff --git a/test/unit/skill-zip.test.ts b/test/unit/skill-zip.test.ts
new file mode 100644
index 00000000..aeb7380c
--- /dev/null
+++ b/test/unit/skill-zip.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { resolveSkillZipEntries } from '../../layers/registry/app/utils/skill-zip'
+
+describe('skill zip entries', () => {
+ it('pins every file to the commit and roots the ZIP at the Skill folder', () => {
+ expect(resolveSkillZipEntries({
+ owner: 'anthropics',
+ repo: 'skills',
+ ref: '4e6907a',
+ skillPath: 'skills/pdf/SKILL.md',
+ name: 'pdf',
+ files: [{ path: 'reference.md' }, { path: 'scripts/fill form.py' }],
+ })).toEqual([
+ { zipPath: 'pdf/SKILL.md', url: 'https://raw.githubusercontent.com/anthropics/skills/4e6907a/skills/pdf/SKILL.md' },
+ { zipPath: 'pdf/reference.md', url: 'https://raw.githubusercontent.com/anthropics/skills/4e6907a/skills/pdf/reference.md' },
+ { zipPath: 'pdf/scripts/fill form.py', url: 'https://raw.githubusercontent.com/anthropics/skills/4e6907a/skills/pdf/scripts/fill%20form.py' },
+ ])
+ })
+
+ it('handles a Skill at the repository root', () => {
+ expect(resolveSkillZipEntries({ owner: 'o', repo: 'r', ref: 'main', skillPath: 'SKILL.md', name: 'r', files: [] }))
+ .toEqual([{ zipPath: 'r/SKILL.md', url: 'https://raw.githubusercontent.com/o/r/main/SKILL.md' }])
+ })
+})