chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import'
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Chip, ChipInput, ChipModalField, Loader } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { importSkillContract } from '@/lib/api/contracts'
|
||||
import {
|
||||
extractSkillFromZip,
|
||||
parseSkillMarkdown,
|
||||
} from '@/app/workspace/[workspaceId]/skills/components/utils'
|
||||
|
||||
interface ImportedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SkillImportProps {
|
||||
onImport: (data: ImportedSkill) => void
|
||||
}
|
||||
|
||||
type ImportState = 'idle' | 'loading' | 'error'
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
|
||||
|
||||
function isAcceptedFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
export function SkillImport({ onImport }: SkillImportProps) {
|
||||
const [githubUrl, setGithubUrl] = useState('')
|
||||
const [githubState, setGithubState] = useState<ImportState>('idle')
|
||||
const [githubError, setGithubError] = useState('')
|
||||
|
||||
const [fileState, setFileState] = useState<ImportState>('idle')
|
||||
const [fileError, setFileError] = useState('')
|
||||
|
||||
const handleGithubImport = useCallback(async () => {
|
||||
const trimmed = githubUrl.trim()
|
||||
if (!trimmed) {
|
||||
setGithubError('Please enter a GitHub URL')
|
||||
setGithubState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setGithubState('loading')
|
||||
setGithubError('')
|
||||
|
||||
try {
|
||||
const data = await requestJson(importSkillContract, { body: { url: trimmed } })
|
||||
const parsed = parseSkillMarkdown(data.content)
|
||||
setGithubState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Failed to import from GitHub')
|
||||
setGithubError(message)
|
||||
setGithubState('error')
|
||||
}
|
||||
}, [githubUrl, onImport])
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!isAcceptedFile(file)) {
|
||||
setFileError('Unsupported file type. Use .md or .zip files.')
|
||||
setFileState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setFileState('loading')
|
||||
setFileError('')
|
||||
|
||||
try {
|
||||
let rawContent: string
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setFileError('ZIP file is too large (max 5 MB)')
|
||||
setFileState('error')
|
||||
return
|
||||
}
|
||||
rawContent = await extractSkillFromZip(file)
|
||||
} else {
|
||||
rawContent = await file.text()
|
||||
}
|
||||
|
||||
const parsed = parseSkillMarkdown(rawContent)
|
||||
setFileState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Failed to process file')
|
||||
setFileError(message)
|
||||
setFileState('error')
|
||||
}
|
||||
},
|
||||
[onImport]
|
||||
)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const file = files[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ChipModalField type='custom' title='Import from GitHub' error={githubError || undefined}>
|
||||
<div className='flex gap-2'>
|
||||
<ChipInput
|
||||
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value)
|
||||
if (githubError) setGithubError('')
|
||||
}}
|
||||
disabled={githubState === 'loading'}
|
||||
className='min-w-0 flex-1'
|
||||
/>
|
||||
<Chip
|
||||
flush
|
||||
onClick={handleGithubImport}
|
||||
disabled={githubState === 'loading' || !githubUrl.trim()}
|
||||
>
|
||||
{githubState === 'loading' ? <Loader className='size-[14px]' animate /> : 'Fetch'}
|
||||
</Chip>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
|
||||
<ImportDivider />
|
||||
|
||||
<ChipModalField
|
||||
type='file'
|
||||
title='Upload File'
|
||||
accept='.md,.zip'
|
||||
onChange={handleFiles}
|
||||
loading={fileState === 'loading'}
|
||||
label={fileState === 'loading' ? 'Importing…' : undefined}
|
||||
description='.md file with YAML frontmatter, or .zip containing a SKILL.md'
|
||||
error={fileError || undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportDivider() {
|
||||
return (
|
||||
<div className='flex items-center gap-3 px-2'>
|
||||
<div className='h-px flex-1 bg-[var(--border)]' />
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>or</span>
|
||||
<div className='h-px flex-1 bg-[var(--border)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal'
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipModalTabs,
|
||||
chipFieldSurfaceClass,
|
||||
cn,
|
||||
} from '@sim/emcn'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import'
|
||||
import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
||||
|
||||
const RichMarkdownField = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
|
||||
).then((m) => m.RichMarkdownField),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className={cn('min-h-[200px]', chipFieldSurfaceClass)} />,
|
||||
}
|
||||
)
|
||||
|
||||
interface SkillModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: () => void
|
||||
onDelete?: (skillId: string) => void
|
||||
initialValues?: SkillDefinition
|
||||
}
|
||||
|
||||
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string
|
||||
description?: string
|
||||
content?: string
|
||||
general?: string
|
||||
}
|
||||
|
||||
type TabValue = 'create' | 'import'
|
||||
|
||||
export function SkillModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
initialValues,
|
||||
}: SkillModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const createSkill = useCreateSkill()
|
||||
const updateSkill = useUpdateSkill()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
/**
|
||||
* Bumped to remount the seed-once rich Content editor whenever `content` is set programmatically — a
|
||||
* reset from a changed `initialValues` or a destructured SKILL.md paste — so the editor re-seeds (an
|
||||
* `initialValues` change for the same skill keeps the React key otherwise stable).
|
||||
*/
|
||||
const [contentSeed, setContentSeed] = useState(0)
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('create')
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||
|
||||
// Reset by skill id, not object identity — a background refetch for the same open skill must not clobber an in-progress edit.
|
||||
if ((open && !prevOpen) || (open && initialValues?.id !== prevInitialValues?.id)) {
|
||||
setName(initialValues?.name ?? '')
|
||||
setDescription(initialValues?.description ?? '')
|
||||
setContent(initialValues?.content ?? '')
|
||||
setErrors({})
|
||||
setActiveTab('create')
|
||||
setContentSeed((seed) => seed + 1)
|
||||
}
|
||||
if (open !== prevOpen) setPrevOpen(open)
|
||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
||||
|
||||
const hasChanges =
|
||||
!initialValues ||
|
||||
name !== initialValues.name ||
|
||||
description !== initialValues.description ||
|
||||
content !== initialValues.content
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: FieldErrors = {}
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Name is required'
|
||||
} else if (name.length > 64) {
|
||||
newErrors.name = 'Name must be 64 characters or less'
|
||||
} else if (!KEBAB_CASE_REGEX.test(name)) {
|
||||
newErrors.name = 'Name must be kebab-case (e.g. my-skill)'
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
newErrors.description = 'Description is required'
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
newErrors.content = 'Content is required'
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (initialValues) {
|
||||
await updateSkill.mutateAsync({
|
||||
workspaceId,
|
||||
skillId: initialValues.id,
|
||||
updates: { name, description, content },
|
||||
})
|
||||
} else {
|
||||
await createSkill.mutateAsync({
|
||||
workspaceId,
|
||||
skill: { name, description, content },
|
||||
})
|
||||
}
|
||||
onSave()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message.includes('already exists')
|
||||
? error.message
|
||||
: 'Failed to save skill. Please try again.'
|
||||
setErrors({ general: message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyImportedSkill = (data: { name: string; description: string; content: string }) => {
|
||||
setName(data.name)
|
||||
setDescription(data.description)
|
||||
setContent(data.content)
|
||||
setErrors({})
|
||||
setContentSeed((seed) => seed + 1)
|
||||
}
|
||||
|
||||
const handleImport = (data: { name: string; description: string; content: string }) => {
|
||||
applyImportedSkill(data)
|
||||
setActiveTab('create')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasting a full SKILL.md destructures it into the fields. Gated on a real YAML `name:` key — a
|
||||
* stray `---` thematic break or a heading-only snippet pastes as ordinary content instead of
|
||||
* silently overwriting all three fields.
|
||||
*/
|
||||
const handleContentPaste = (text: string): boolean => {
|
||||
const parsed = parseSkillMarkdown(text)
|
||||
if (!parsed.nameFromFrontmatter) return false
|
||||
applyImportedSkill(parsed)
|
||||
return true
|
||||
}
|
||||
|
||||
const isEditing = !!initialValues
|
||||
const readOnly = !!initialValues?.readOnly
|
||||
const showFooter = activeTab === 'create' || isEditing
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
srTitle={isEditing ? 'Edit Skill' : 'Add Skill'}
|
||||
size='lg'
|
||||
>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>
|
||||
{isEditing ? 'Edit Skill' : 'Add Skill'}
|
||||
</ChipModalHeader>
|
||||
|
||||
<ChipModalBody>
|
||||
{!isEditing && (
|
||||
<ChipModalTabs
|
||||
tabs={[
|
||||
{ value: 'create', label: 'Create' },
|
||||
{ value: 'import', label: 'Import' },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={(value) => setActiveTab(value as TabValue)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'create' || isEditing ? (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={(value) => {
|
||||
setName(value)
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
placeholder='my-skill-name'
|
||||
required
|
||||
error={errors.name}
|
||||
hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)'
|
||||
/>
|
||||
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Description'
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
setDescription(value)
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
placeholder='What this skill does and when to use it...'
|
||||
maxLength={1024}
|
||||
required
|
||||
error={errors.description}
|
||||
/>
|
||||
|
||||
<ChipModalField type='custom' title='Content' required error={errors.content}>
|
||||
<RichMarkdownField
|
||||
key={`${initialValues?.id ?? 'new'}:${contentSeed}`}
|
||||
value={content}
|
||||
onChange={(value) => {
|
||||
setContent(value)
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
placeholder='Skill instructions in markdown...'
|
||||
minHeight={200}
|
||||
disabled={readOnly || saving}
|
||||
error={!!errors.content}
|
||||
workspaceId={workspaceId}
|
||||
onPasteText={handleContentPaste}
|
||||
/>
|
||||
</ChipModalField>
|
||||
|
||||
<ChipModalError>{errors.general}</ChipModalError>
|
||||
</>
|
||||
) : (
|
||||
<SkillImport onImport={handleImport} />
|
||||
)}
|
||||
</ChipModalBody>
|
||||
|
||||
{showFooter && (
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={readOnly}
|
||||
secondaryActions={
|
||||
isEditing && onDelete
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => onDelete(initialValues.id),
|
||||
variant: 'destructive',
|
||||
disabled: readOnly,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
primaryAction={{
|
||||
label: saving ? 'Saving...' : isEditing ? 'Update' : 'Create',
|
||||
onClick: handleSave,
|
||||
disabled: readOnly || saving || !hasChanges,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||
|
||||
describe('parseSkillMarkdown', () => {
|
||||
it('parses standard SKILL.md with name, description, and body', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: my-skill',
|
||||
'description: Does something useful',
|
||||
'---',
|
||||
'',
|
||||
'# Instructions',
|
||||
'Use this skill to do things.',
|
||||
].join('\n')
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-skill',
|
||||
description: 'Does something useful',
|
||||
content: '# Instructions\nUse this skill to do things.',
|
||||
nameFromFrontmatter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('strips single and double quotes from frontmatter values', () => {
|
||||
const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-skill',
|
||||
description: 'A quoted description',
|
||||
content: 'Body',
|
||||
nameFromFrontmatter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves colons inside description values', () => {
|
||||
const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'api-tool',
|
||||
description: 'API key: required for auth',
|
||||
content: 'Body',
|
||||
nameFromFrontmatter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores unknown frontmatter fields', () => {
|
||||
const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('x')
|
||||
expect(result.description).toBe('y')
|
||||
expect(result.content).toBe('Body')
|
||||
})
|
||||
|
||||
it('infers name from heading when frontmatter has no name field', () => {
|
||||
const input =
|
||||
'---\ndescription: A tool for blocks\nargument-hint: <name>\n---\n\n# Add Block Skill\n\nContent here.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'add-block-skill',
|
||||
description: 'A tool for blocks',
|
||||
content: '# Add Block Skill\n\nContent here.',
|
||||
nameFromFrontmatter: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('infers name from heading when there is no frontmatter at all', () => {
|
||||
const input = '# My Cool Tool\n\nSome instructions.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-cool-tool',
|
||||
description: '',
|
||||
content: '# My Cool Tool\n\nSome instructions.',
|
||||
nameFromFrontmatter: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty name when there is no frontmatter and no heading', () => {
|
||||
const input = 'Just some plain text without any structure.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
content: 'Just some plain text without any structure.',
|
||||
nameFromFrontmatter: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(parseSkillMarkdown('')).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
nameFromFrontmatter: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles frontmatter with empty name value', () => {
|
||||
const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('fallback-heading')
|
||||
expect(result.description).toBe('Has a description')
|
||||
})
|
||||
|
||||
it('handles frontmatter with no body', () => {
|
||||
const input = '---\nname: solo\ndescription: Just frontmatter\n---'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'solo',
|
||||
description: 'Just frontmatter',
|
||||
content: '',
|
||||
nameFromFrontmatter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles unclosed frontmatter as plain content', () => {
|
||||
const input = '---\nname: broken\nno closing delimiter'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('')
|
||||
expect(result.content).toBe(input)
|
||||
})
|
||||
|
||||
it('flags nameFromFrontmatter only for a real YAML name key (the paste-destructure gate)', () => {
|
||||
expect(parseSkillMarkdown('---\nname: real\n---\nBody').nameFromFrontmatter).toBe(true)
|
||||
// A `---` thematic break + heading must NOT count — it would wrongly destructure on paste.
|
||||
expect(parseSkillMarkdown('---\n# Setup Guide\nnotes').nameFromFrontmatter).toBe(false)
|
||||
// A changelog whose second `---` closes the regex but has no name key.
|
||||
expect(parseSkillMarkdown('---\n\n## v2.0\n\n---\n\n## v1.0').nameFromFrontmatter).toBe(false)
|
||||
})
|
||||
|
||||
it('trims whitespace from input', () => {
|
||||
const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('trimmed')
|
||||
expect(result.content).toBe('Body')
|
||||
})
|
||||
|
||||
it('truncates inferred heading names to 64 characters', () => {
|
||||
const longHeading = `# ${'A'.repeat(100)}`
|
||||
const result = parseSkillMarkdown(longHeading)
|
||||
expect(result.name.length).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('sanitizes special characters in inferred heading names', () => {
|
||||
const input = '# Hello, World! (v2) — Updated'
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('hello-world-v2-updated')
|
||||
})
|
||||
|
||||
it('handles h2 and h3 headings for name inference', () => {
|
||||
expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
|
||||
expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
|
||||
})
|
||||
|
||||
it('does not match h4+ headings for name inference', () => {
|
||||
expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
|
||||
})
|
||||
|
||||
it('uses first heading even when multiple exist', () => {
|
||||
const input = '# First\n\n## Second\n\n### Third'
|
||||
expect(parseSkillMarkdown(input).name).toBe('first')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSkillFromZip', () => {
|
||||
async function makeZipBuffer(files: Record<string, string>): Promise<Uint8Array> {
|
||||
const zip = new JSZip()
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
zip.file(path, content)
|
||||
}
|
||||
return zip.generateAsync({ type: 'uint8array' })
|
||||
}
|
||||
|
||||
it('extracts SKILL.md at root level', async () => {
|
||||
const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('---\nname: root\n---\nContent')
|
||||
})
|
||||
|
||||
it('extracts SKILL.md from a nested directory', async () => {
|
||||
const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('---\nname: nested\n---\nBody')
|
||||
})
|
||||
|
||||
it('prefers the shallowest SKILL.md when multiple exist', async () => {
|
||||
const data = await makeZipBuffer({
|
||||
'deep/nested/SKILL.md': 'deep',
|
||||
'SKILL.md': 'root',
|
||||
'other/SKILL.md': 'other',
|
||||
})
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('root')
|
||||
})
|
||||
|
||||
it('throws when no SKILL.md is found', async () => {
|
||||
const data = await makeZipBuffer({ 'README.md': 'No skill here' })
|
||||
await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
/** True only when `name` came from a real YAML `name:` key (not inferred from a heading). */
|
||||
nameFromFrontmatter: boolean
|
||||
}
|
||||
|
||||
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md string with optional YAML frontmatter into structured fields.
|
||||
*
|
||||
* Expected format:
|
||||
* ```
|
||||
* ---
|
||||
* name: my-skill
|
||||
* description: What this skill does
|
||||
* ---
|
||||
* # Markdown content here...
|
||||
* ```
|
||||
*
|
||||
* If no frontmatter is present, the entire text becomes the content field.
|
||||
*/
|
||||
export function parseSkillMarkdown(raw: string): ParsedSkill {
|
||||
const trimmed = raw.replace(/\r\n/g, '\n').trim()
|
||||
const match = trimmed.match(FRONTMATTER_REGEX)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
name: inferNameFromHeading(trimmed),
|
||||
description: '',
|
||||
content: trimmed,
|
||||
nameFromFrontmatter: false,
|
||||
}
|
||||
}
|
||||
|
||||
const frontmatter = match[1]
|
||||
const body = (match[2] ?? '').trim()
|
||||
|
||||
let name = ''
|
||||
let description = ''
|
||||
|
||||
for (const line of frontmatter.split('\n')) {
|
||||
const colonIdx = line.indexOf(':')
|
||||
if (colonIdx === -1) continue
|
||||
|
||||
const key = line.slice(0, colonIdx).trim().toLowerCase()
|
||||
const value = line
|
||||
.slice(colonIdx + 1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
|
||||
if (key === 'name') {
|
||||
name = value
|
||||
} else if (key === 'description') {
|
||||
description = value
|
||||
}
|
||||
}
|
||||
|
||||
const nameFromFrontmatter = name !== ''
|
||||
if (!name) {
|
||||
name = inferNameFromHeading(body)
|
||||
}
|
||||
|
||||
return { name, description, content: body, nameFromFrontmatter }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
|
||||
*/
|
||||
function inferNameFromHeading(markdown: string): string {
|
||||
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
|
||||
if (!headingMatch) return ''
|
||||
|
||||
return headingMatch[1]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the SKILL.md content from a ZIP archive.
|
||||
* Searches for a file named SKILL.md at any depth within the archive.
|
||||
* Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
|
||||
*/
|
||||
export async function extractSkillFromZip(
|
||||
data: File | Blob | ArrayBuffer | Uint8Array
|
||||
): Promise<string> {
|
||||
const zip = await JSZip.loadAsync(data)
|
||||
|
||||
const candidates: string[] = []
|
||||
zip.forEach((relativePath, entry) => {
|
||||
if (!entry.dir && relativePath.endsWith('SKILL.md')) {
|
||||
candidates.push(relativePath)
|
||||
}
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No SKILL.md file found in the ZIP archive')
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const depthA = a.split('/').length
|
||||
const depthB = b.split('/').length
|
||||
return depthA - depthB
|
||||
})
|
||||
|
||||
const content = await zip.file(candidates[0])!.async('string')
|
||||
return content
|
||||
}
|
||||
Reference in New Issue
Block a user