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-/) */ function tarball(sha: string, files: Record, links: Record = {}): 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 = { 'acme/tools': SHA1, 'acme/closed': SHA1, 'other/tools': SHA1 } const repos: Record = { [`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' }) }) })