| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- import { execFileSync } from 'node:child_process'
- import fs from 'node:fs'
- import os from 'node:os'
- import path from 'node:path'
- import { describe, expect, it } from 'vitest'
- import { resolveSkills } from '../src/providers/claude/skills'
- import { skillsToDisable } from '../src/providers/codex/skills'
- import { CommunitySkillService } from '../src/services/community-skills'
- import { frontmatter } from '../src/lib/skill-files'
- const SHA1 = 'c'.repeat(40)
- const SHA2 = 'd'.repeat(40)
- /** 构造与 GitHub codeload 相同格式的仓库快照(顶层目录 repo-<sha>/) */
- function tarball(sha: string, files: Record<string, string>, links: Record<string, string> = {}): Buffer {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'crepo-'))
- const top = path.join(root, `repo-${sha}`)
- for (const [rel, body] of Object.entries(files)) {
- fs.mkdirSync(path.dirname(path.join(top, rel)), { recursive: true })
- fs.writeFileSync(path.join(top, rel), body)
- }
- for (const [rel, target] of Object.entries(links)) fs.symlinkSync(target, path.join(top, rel))
- const out = path.join(root, 'repo.tar.gz')
- execFileSync('tar', ['-czf', out, '-C', root, `repo-${sha}`])
- return fs.readFileSync(out)
- }
- const skill = (name: string, extra = '') => `---\nname: ${name}\ndescription: >-\n ${name} does\n things\n${extra}---\nbody\n`
- describe('CommunitySkillService', () => {
- const base = fs.mkdtempSync(path.join(os.tmpdir(), 'community-'))
- const roots = { cache: path.join(base, 'cache'), plugin: path.join(base, 'community-skills') }
- const heads: Record<string, string> = { 'acme/tools': SHA1, 'acme/closed': SHA1, 'other/tools': SHA1 }
- const repos: Record<string, Buffer> = {
- [`acme/tools@${SHA1}`]: tarball(SHA1, {
- 'LICENSE': 'MIT License\n\nPermission is hereby granted, free of charge',
- 'skills/csv-clean/SKILL.md': skill('csv-clean'),
- 'skills/csv-clean/scripts/clean.py': 'print(1)',
- 'skills/renamed-dir/SKILL.md': skill('chart-kit'),
- 'docs/csv-clean/SKILL.md': skill('csv-clean-docs'),
- }, { 'skills/csv-clean/passwd': '/etc/passwd' }),
- [`acme/tools@${SHA2}`]: tarball(SHA2, {
- 'LICENSE': 'MIT License',
- 'skills/csv-clean/SKILL.md': skill('csv-clean', 'version: 2\n'),
- }),
- [`acme/closed@${SHA1}`]: tarball(SHA1, {
- 'skills/secret/SKILL.md': skill('secret', 'license: Complete terms in LICENSE.txt\n'),
- 'skills/secret/LICENSE.txt': '© Anthropic, PBC. All rights reserved. Use is governed by your agreement with Anthropic.',
- 'skills/nolicense/SKILL.md': skill('nolicense'),
- 'skills/declared/SKILL.md': skill('declared', 'license: Apache-2.0\n'),
- }),
- [`other/tools@${SHA1}`]: tarball(SHA1, { 'LICENSE': 'Apache License\nVersion 2.0', 'csv-clean/SKILL.md': skill('csv-clean') }),
- }
- const fetcher = async (url: string) => {
- if (url.startsWith('https://skills.sh/api/search')) {
- return new Response(JSON.stringify({ skills: [
- { id: 'acme/tools/csv-clean', source: 'acme/tools', skillId: 'csv-clean', name: 'csv-clean', installs: 9 },
- { id: 'anthropics/skills/pdf', source: 'anthropics/skills', skillId: 'pdf', name: 'pdf', installs: 1 },
- { id: 'bad', source: '../x', skillId: 'x' },
- ] }))
- }
- let m = /repos\/([^/]+\/[^/]+)\/commits\/HEAD$/.exec(url)
- if (m) return heads[m[1]!] ? new Response(JSON.stringify({ sha: heads[m[1]!] })) : new Response('{}', { status: 404 })
- m = /codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/(\w+)$/.exec(url)
- const buf = m && repos[`${m[1]}@${m[2]}`]
- return buf ? new Response(buf) : new Response('nf', { status: 404 })
- }
- const audits: string[] = []
- const svc = new CommunitySkillService(s => audits.push(s), fetcher, roots)
- it('初始化插件目录:plugin.json 名称为 community-skills', () => {
- expect(JSON.parse(fs.readFileSync(path.join(roots.plugin, '.claude-plugin/plugin.json'), 'utf8')).name).toBe('community-skills')
- expect(svc.pluginRef()).toBeNull()
- })
- it('搜索:过滤非法来源,标记官方仓库', async () => {
- const r = await svc.search('csv')
- expect(r.map(s => [s.source, s.skillId, s.blocked !== null])).toEqual([['acme/tools', 'csv-clean', false], ['anthropics/skills', 'pdf', true]])
- expect(await svc.search('a')).toEqual([])
- })
- it('预览:定位技能目录、识别许可证与脚本、忽略符号链接', async () => {
- const p = await svc.preview('acme/tools', 'csv-clean')
- expect(p).toMatchObject({ name: 'csv-clean', path: 'skills/csv-clean', sha: SHA1, license: 'open', licenseName: 'MIT', licenseFrom: 'repo', hasScripts: true, installed: null, conflict: null })
- expect(p.description).toBe('csv-clean does things')
- expect(p.files.map(f => f.path)).toEqual(['SKILL.md', 'scripts/clean.py'])
- // 目录名与技能名不同时按 SKILL.md 的 name 匹配
- expect((await svc.preview('acme/tools', 'chart-kit')).path).toBe('skills/renamed-dir')
- await expect(svc.preview('acme/tools', 'missing')).rejects.toThrow(/不存在/)
- await expect(svc.preview('anthropics/skills', 'pdf')).rejects.toThrow(/Claude/)
- await expect(svc.preview('a/b; rm', 'x')).rejects.toThrow(/owner\/repo/)
- })
- it('许可证:专有许可与无法识别的许可拒绝安装;SKILL.md 声明开源许可可以安装', async () => {
- const secret = await svc.preview('acme/closed', 'secret')
- expect([secret.license, secret.licenseFrom]).toEqual(['restricted', 'skill'])
- expect(() => svc.install('acme/closed', 'secret', SHA1)).toThrow(/只能安装开源许可/)
- expect((await svc.preview('acme/closed', 'nolicense')).license).toBe('unknown')
- expect(() => svc.install('acme/closed', 'nolicense', SHA1)).toThrow(/无法识别/)
- expect(await svc.preview('acme/closed', 'declared')).toMatchObject({ license: 'open', licenseName: 'Apache-2.0', licenseFrom: 'frontmatter' })
- })
- it('安装:只复制普通文件并写入标记;必须先预览', async () => {
- expect(() => svc.install('acme/tools', 'csv-clean', SHA2)).toThrow(/先预览/)
- const list = svc.install('acme/tools', 'csv-clean', SHA1)
- expect(list).toMatchObject([{ id: 'community-skills:csv-clean', name: 'csv-clean', source: 'acme/tools', sha: SHA1, license: 'MIT' }])
- const dir = path.join(roots.plugin, 'skills/csv-clean')
- expect(fs.existsSync(path.join(dir, 'scripts/clean.py'))).toBe(true)
- expect(fs.existsSync(path.join(dir, 'passwd'))).toBe(false)
- expect(svc.pluginRef()).toEqual({ name: 'community-skills', path: roots.plugin, optIn: true, skills: [{ id: 'community-skills:csv-clean', loadId: 'community-skills:csv-clean' }] })
- expect((await svc.search('csv'))[0]!.installed).toBe(true)
- })
- it('同名冲突;仓库更新后按新提交升级;卸载', async () => {
- const other = await svc.preview('other/tools', 'csv-clean')
- expect(other.conflict).toMatch(/acme\/tools/)
- expect(() => svc.install('other/tools', 'csv-clean', SHA1)).toThrow(/请先卸载/)
- heads['acme/tools'] = SHA2
- const p = await svc.preview('acme/tools', 'csv-clean')
- expect(p).toMatchObject({ sha: SHA2, installed: { sha: SHA1 } })
- expect(svc.install('acme/tools', 'csv-clean', SHA2)[0]!.sha).toBe(SHA2)
- // 同一仓库只保留最新快照
- expect(fs.readdirSync(path.join(roots.cache, 'acme__tools'))).toEqual([SHA2])
- expect(() => svc.uninstall('../x')).toThrow()
- expect(svc.uninstall('csv-clean')).toEqual([])
- expect(audits.filter(a => a.startsWith('安装第三方技能'))).toHaveLength(2)
- })
- })
- describe('第三方技能需要在 Profile 中单独启用', () => {
- it('Claude:all 不包含 optIn 插件,显式选择时加载', () => {
- const plugins = [
- { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
- { name: 'community-skills', path: '/c', optIn: true, skills: [{ id: 'community-skills:x', loadId: 'community-skills:x' }] },
- ]
- expect(resolveSkills('all', plugins)).toEqual({ plugins: [{ type: 'local', path: '/a' }], skills: ['agent-skills:r'] })
- expect(resolveSkills(['community-skills:x'], plugins)).toEqual({ plugins: [{ type: 'local', path: '/c' }], skills: ['community-skills:x'] })
- })
- it('Codex:all 禁用第三方技能,显式选择时保留', () => {
- const all = [
- { name: 'agent-skills:r', path: '/a/r/SKILL.md' },
- { name: 'community-skills:x', path: '/c/x/SKILL.md' },
- // SKILL.md 的 name 不是目录名时,按目录名生成 ID
- { name: 'community-skills:Pretty Name', path: '/c/pretty-name/SKILL.md' },
- ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
- expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/c/x/SKILL.md', '/c/pretty-name/SKILL.md'])
- expect(skillsToDisable(all, ['community-skills:x', 'community-skills:pretty-name'], '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/a/r/SKILL.md'])
- })
- })
- describe('frontmatter', () => {
- it('支持多行写法与引号', () => {
- expect(frontmatter('---\nname: "a"\ndescription: |\n line1\n line2\nlicense: MIT\n---\n')).toEqual({ name: 'a', description: 'line1\nline2', license: 'MIT' })
- })
- })
|