feat(publish): allow uploading a skill folder directly

Publishing required a hand-made .zip. Local agent skills live as folders,
and repackaging them by hand often smuggles in tool metadata (__MACOSX/,
.DS_Store, ...) that clutters the package (#737).

Add an "upload a folder" option to the publish dropzone: pick a folder
(webkitdirectory), and the browser packages it into a clean STORE-method
zip via a dependency-free writer, dropping VCS/build/OS junk (.git/,
node_modules/, __pycache__/, .DS_Store, ...). The resulting zip flows
through the exact same upload/publish path, and the server already strips
a single root directory, so paths are preserved as-is.

Packaging (folder-zip.ts) is isolated and unit-tested (CRC-32 vectors, zip
structure, junk filtering); the UploadZone gains an optional onFolderSelect
prop so existing callers are unaffected.

Closes #737

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
FenjuFu 2026-08-25 20:12:40 +08:00
parent 954dfce7a4
commit 0fbc55f917
6 changed files with 384 additions and 42 deletions

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
collectFolderEntries,
createZipBlob,
crc32,
isIgnoredPath,
packageFolderAsZip,
} from './folder-zip'
const utf8 = new TextEncoder()
function fileAt(relativePath: string, content = 'x'): File {
const name = relativePath.split('/').pop() || relativePath
const file = new File([content], name)
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath })
return file
}
async function bytesOf(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer())
}
describe('isIgnoredPath', () => {
it('keeps normal skill files', () => {
expect(isIgnoredPath('my-skill/SKILL.md')).toBe(false)
expect(isIgnoredPath('my-skill/scripts/run.sh')).toBe(false)
})
it('drops VCS, build and OS junk', () => {
expect(isIgnoredPath('my-skill/.git/config')).toBe(true)
expect(isIgnoredPath('my-skill/node_modules/x/index.js')).toBe(true)
expect(isIgnoredPath('my-skill/__pycache__/m.pyc')).toBe(true)
expect(isIgnoredPath('my-skill/.DS_Store')).toBe(true)
expect(isIgnoredPath('my-skill/._resource')).toBe(true)
expect(isIgnoredPath('my-skill/Thumbs.db')).toBe(true)
})
})
describe('crc32', () => {
it('matches known CRC-32/ISO-HDLC vectors', () => {
expect(crc32(utf8.encode(''))).toBe(0x00000000)
expect(crc32(utf8.encode('a'))).toBe(0xe8b7be43)
expect(crc32(utf8.encode('abc'))).toBe(0x352441c2)
})
})
describe('createZipBlob', () => {
it('writes a STORE archive with local, central and EOCD records', async () => {
const blob = createZipBlob([{ path: 'SKILL.md', data: utf8.encode('hello') }])
const bytes = await bytesOf(blob)
const view = new DataView(bytes.buffer)
// Local file header signature at offset 0.
expect(view.getUint32(0, true)).toBe(0x04034b50)
// Contains a central directory header and an end-of-central-directory record.
const eocd = bytes.length - 22
expect(view.getUint32(eocd, true)).toBe(0x06054b50)
expect(view.getUint16(eocd + 10, true)).toBe(1) // total entries
// Central dir offset points at a central directory header signature.
const cdOffset = view.getUint32(eocd + 16, true)
expect(view.getUint32(cdOffset, true)).toBe(0x02014b50)
})
})
describe('collectFolderEntries', () => {
it('filters junk and sorts remaining files by path', async () => {
const entries = await collectFolderEntries([
fileAt('my-skill/scripts/run.sh', 'run'),
fileAt('my-skill/.git/config', 'gitcfg'),
fileAt('my-skill/SKILL.md', 'md'),
])
expect(entries.map((e) => e.path)).toEqual(['my-skill/SKILL.md', 'my-skill/scripts/run.sh'])
})
})
describe('packageFolderAsZip', () => {
it('names the zip after the top-level folder', async () => {
const file = await packageFolderAsZip([fileAt('my-skill/SKILL.md', 'md')])
expect(file.name).toBe('my-skill.zip')
expect(file.type).toBe('application/zip')
expect(file.size).toBeGreaterThan(0)
})
it('throws when everything was filtered out', async () => {
await expect(packageFolderAsZip([fileAt('my-skill/.git/config', 'x')])).rejects.toThrow(
'empty-folder'
)
})
})

View file

@ -0,0 +1,189 @@
/**
* Dependency-free packaging of a selected folder into a skill ZIP.
*
* Browsers expose a picked folder as a flat FileList (each File carries a
* `webkitRelativePath` like `my-skill/SKILL.md`). We build a STORE-method ZIP
* (no compression skill packages are small text files, and STORE keeps this
* dependency-free) from those files so the result flows through the exact same
* upload/publish path as a hand-made ZIP.
*
* We drop VCS/build/OS junk that a real on-disk folder almost always contains
* (`.git/`, `node_modules/`, `.DS_Store`, ) so it never bloats the package or
* the file-count limit. The server additionally strips a single root directory
* and OS-metadata entries, so paths are kept as-is (`my-skill/SKILL.md`).
*/
/** Directory names whose entire subtree is excluded from the package. */
const IGNORED_DIR_SEGMENTS = new Set([
'.git',
'.svn',
'.hg',
'node_modules',
'__pycache__',
'__MACOSX',
])
/** Exact file names that are always excluded. */
const IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini'])
/** Returns true if a relative path should be excluded from the package. */
export function isIgnoredPath(relativePath: string): boolean {
const parts = relativePath.split('/')
const name = parts[parts.length - 1]
if (!name) return true // trailing slash / directory marker
if (parts.some((segment) => IGNORED_DIR_SEGMENTS.has(segment))) return true
if (IGNORED_FILE_NAMES.has(name)) return true
if (name.startsWith('._')) return true
if (name.endsWith('.pyc') || name.endsWith('.swp')) return true
return false
}
// --- CRC-32 (IEEE 802.3, polynomial 0xEDB88320) -------------------------------
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[n] = c >>> 0
}
return table
})()
export function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff
for (let i = 0; i < bytes.length; i++) {
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
// --- ZIP writer (STORE method, no data descriptors) ---------------------------
export interface ZipEntry {
path: string
data: Uint8Array
}
const utf8 = new TextEncoder()
/**
* Builds a ZIP archive (STORE method) containing the given entries and returns
* it as a Blob. 32-bit size fields are used; skill packages are far below the
* 4 GB boundary where ZIP64 would be required.
*/
export function createZipBlob(entries: ZipEntry[]): Blob {
const localParts: Uint8Array[] = []
const centralParts: Uint8Array[] = []
let offset = 0
for (const entry of entries) {
const nameBytes = utf8.encode(entry.path)
const crc = crc32(entry.data)
const size = entry.data.length
const local = new Uint8Array(30 + nameBytes.length)
const lv = new DataView(local.buffer)
lv.setUint32(0, 0x04034b50, true) // local file header signature
lv.setUint16(4, 20, true) // version needed
lv.setUint16(6, 0x0800, true) // flags: bit 11 = UTF-8 names
lv.setUint16(8, 0, true) // method: STORE
lv.setUint16(10, 0, true) // mod time
lv.setUint16(12, 0, true) // mod date
lv.setUint32(14, crc, true)
lv.setUint32(18, size, true) // compressed size (== uncompressed for STORE)
lv.setUint32(22, size, true) // uncompressed size
lv.setUint16(26, nameBytes.length, true)
lv.setUint16(28, 0, true) // extra length
local.set(nameBytes, 30)
localParts.push(local, entry.data)
const central = new Uint8Array(46 + nameBytes.length)
const cv = new DataView(central.buffer)
cv.setUint32(0, 0x02014b50, true) // central directory header signature
cv.setUint16(4, 20, true) // version made by
cv.setUint16(6, 20, true) // version needed
cv.setUint16(8, 0x0800, true) // flags
cv.setUint16(10, 0, true) // method: STORE
cv.setUint16(12, 0, true) // mod time
cv.setUint16(14, 0, true) // mod date
cv.setUint32(16, crc, true)
cv.setUint32(20, size, true)
cv.setUint32(24, size, true)
cv.setUint16(28, nameBytes.length, true)
cv.setUint16(30, 0, true) // extra length
cv.setUint16(32, 0, true) // comment length
cv.setUint16(34, 0, true) // disk number start
cv.setUint16(36, 0, true) // internal attrs
cv.setUint32(38, 0, true) // external attrs
cv.setUint32(42, offset, true) // relative offset of local header
central.set(nameBytes, 46)
centralParts.push(central)
offset += local.length + entry.data.length
}
const centralSize = centralParts.reduce((n, p) => n + p.length, 0)
const eocd = new Uint8Array(22)
const ev = new DataView(eocd.buffer)
ev.setUint32(0, 0x06054b50, true) // end of central directory signature
ev.setUint16(4, 0, true) // disk number
ev.setUint16(6, 0, true) // central dir start disk
ev.setUint16(8, entries.length, true) // entries on this disk
ev.setUint16(10, entries.length, true) // total entries
ev.setUint32(12, centralSize, true) // central dir size
ev.setUint32(16, offset, true) // central dir offset
ev.setUint16(20, 0, true) // comment length
// Concatenate into a single buffer so the Blob part is a Uint8Array<ArrayBuffer>.
const parts = [...localParts, ...centralParts, eocd]
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let pos = 0
for (const part of parts) {
out.set(part, pos)
pos += part.length
}
return new Blob([out], { type: 'application/zip' })
}
// --- Folder -> File ------------------------------------------------------------
/** Reads the picked folder's files into ZIP entries, skipping junk. */
export async function collectFolderEntries(files: File[]): Promise<ZipEntry[]> {
const entries: ZipEntry[] = []
for (const file of files) {
const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
if (isIgnoredPath(path)) continue
const data = new Uint8Array(await file.arrayBuffer())
entries.push({ path, data })
}
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
return entries
}
/** Top-level folder name of a webkitdirectory selection, for naming the zip. */
function rootFolderName(files: File[]): string {
for (const file of files) {
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
if (rel && rel.includes('/')) return rel.slice(0, rel.indexOf('/'))
}
return 'skill'
}
/**
* Packages a picked folder into a `<folder>.zip` File ready for the existing
* upload flow. Throws if every file was filtered out as junk.
*/
export async function packageFolderAsZip(fileList: FileList | File[]): Promise<File> {
const files = Array.from(fileList)
const entries = await collectFolderEntries(files)
if (entries.length === 0) {
throw new Error('empty-folder')
}
const blob = createZipBlob(entries)
return new File([blob], `${rootFolderName(files)}.zip`, { type: 'application/zip' })
}

View file

@ -1,10 +1,12 @@
import { useCallback } from 'react'
import { useCallback, useEffect, useRef, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { cn } from '@/shared/lib/utils'
interface UploadZoneProps {
onFileSelect: (file: File) => void
/** Optional: called with the raw files of a picked folder (webkitdirectory). */
onFolderSelect?: (files: File[]) => void
disabled?: boolean
}
@ -13,8 +15,20 @@ interface UploadZoneProps {
* The component is intentionally stateless so packaging validation can remain in
* the publish flow that knows the surrounding form and backend constraints.
*/
export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZoneProps) {
const { t } = useTranslation()
const folderInputRef = useRef<HTMLInputElement>(null)
// `webkitdirectory` / `directory` are not in React's input attribute types;
// set them imperatively so the folder picker works without an untyped cast.
useEffect(() => {
const el = folderInputRef.current
if (el) {
el.setAttribute('webkitdirectory', '')
el.setAttribute('directory', '')
}
}, [])
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
@ -33,44 +47,75 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
disabled,
})
const handleFolderChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (files && files.length > 0) {
onFolderSelect?.(Array.from(files))
}
// Reset so picking the same folder again re-triggers change.
event.target.value = ''
}
return (
<div
{...getRootProps()}
className={cn(
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
<svg
className={cn(
'w-7 h-7 upload-zone-icon transition-colors',
isDragActive && 'text-primary'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
{isDragActive ? (
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
) : (
<>
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
</>
<div className="flex flex-col gap-3">
<div
{...getRootProps()}
className={cn(
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
<svg
className={cn(
'w-7 h-7 upload-zone-icon transition-colors',
isDragActive && 'text-primary'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
{isDragActive ? (
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
) : (
<>
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
</>
)}
</div>
</div>
{onFolderSelect && (
<div className="text-center">
<input
ref={folderInputRef}
type="file"
className="hidden"
multiple
onChange={handleFolderChange}
disabled={disabled}
/>
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => folderInputRef.current?.click()}
disabled={disabled}
>
{t('upload.folderHint')}
</button>
</div>
)}
</div>
)
}

View file

@ -1212,7 +1212,8 @@
"upload": {
"dropHint": "Drop to upload...",
"dragHint": "Drag a ZIP file here, or click to select",
"formatHint": "Only .zip format supported"
"formatHint": "Only .zip format supported",
"folderHint": "Or select a folder to package and upload"
},
"layout": {
"footerDescription": "Skill registry, providing efficient skill management and distribution for developers."
@ -1389,7 +1390,8 @@
"warningConfirmCancel": "Go back and fix",
"frontmatterFailedTitle": "SKILL.md format is invalid",
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
"selectRequired": "Please select namespace and file"
"selectRequired": "Please select namespace and file",
"folderPackagingFailed": "Could not package the selected folder. Make sure it contains files."
},
"toast": {
"success": "Success",

View file

@ -1212,7 +1212,8 @@
"upload": {
"dropHint": "放开以上传文件...",
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",
"formatHint": "仅支持 .zip 格式"
"formatHint": "仅支持 .zip 格式",
"folderHint": "或选择文件夹,自动打包上传"
},
"layout": {
"footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。"
@ -1389,7 +1390,8 @@
"warningConfirmCancel": "返回修改",
"frontmatterFailedTitle": "SKILL.md 格式有误",
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
"selectRequired": "请选择命名空间和文件"
"selectRequired": "请选择命名空间和文件",
"folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。"
},
"toast": {
"success": "成功",

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { UploadZone } from '@/features/publish/upload-zone'
import { packageFolderAsZip } from '@/features/publish/folder-zip'
import {
extractPrecheckWarnings,
isFrontmatterFailureMessage,
@ -41,6 +42,7 @@ export function PublishPage() {
const [visibility, setVisibility] = useState<string>(prefill.visibility)
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
const [isPackaging, setIsPackaging] = useState(false)
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const publishMutation = usePublishSkill()
@ -66,6 +68,18 @@ export function PublishPage() {
setWarningDialogOpen(false)
}
const handleFolderSelect = async (files: File[]) => {
setIsPackaging(true)
try {
const zip = await packageFolderAsZip(files)
handleFileSelect(zip)
} catch {
toast.error(t('publish.folderPackagingFailed'))
} finally {
setIsPackaging(false)
}
}
const publishSkill = async (confirmWarnings = false) => {
if (!selectedFile || !namespaceSlug) {
toast.error(t('publish.selectRequired'))
@ -201,7 +215,8 @@ export function PublishPage() {
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
<UploadZone
onFileSelect={handleFileSelect}
disabled={publishMutation.isPending}
onFolderSelect={handleFolderSelect}
disabled={publishMutation.isPending || isPackaging}
/>
{selectedFile && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">