Compare commits

...
Author SHA1 Message Date
Gromov Sergei 1d61ffef4b Merge pull request #20 from gromlab-ru/fix/skill-discovery
fix(skills): исключить исходники из обнаружения
2026-09-12 12:01:45 +03:00
Gromov Sergei 518d1a4046 fix(skills): исключить исходники из обнаружения 2026-09-12 10:44:49 +03:00
Gromov Sergei e96eed9522 Merge pull request #19 from gromlab-ru/feat/installable-skills
feat(skills): добавить установку через npx skills add
2026-08-17 09:52:02 +03:00
11 changed files with 186 additions and 8 deletions
+4
View File
@@ -124,3 +124,7 @@ mode A -X-> shared output codegen
2. Не переносите output-логику в core ради устранения дублирования. 2. Не переносите output-логику в core ради устранения дублирования.
3. Не меняйте generated-контракты других adapters автоматически. 3. Не меняйте generated-контракты других adapters автоматически.
4. Проверяйте отсутствие cross-mode imports. 4. Проверяйте отсутствие cross-mode imports.
## AI skills
При изменении исходников или сборки skills следуйте `src/skills/README.md`. После изменения выполните `npm run build:skill`, `npm run check:skills` и `npm run test:skills`.
+18 -1
View File
@@ -28,7 +28,8 @@
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"yaml": "2.9.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -3321,6 +3322,22 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+5 -2
View File
@@ -59,6 +59,8 @@
"build:package": "tsup && tsup --config tsup.browser.config.ts && tsup --config tsup.viewer.config.ts", "build:package": "tsup && tsup --config tsup.browser.config.ts && tsup --config tsup.viewer.config.ts",
"build:skill": "node src/skills/svg-sprites/build.mjs", "build:skill": "node src/skills/svg-sprites/build.mjs",
"check:skill": "node src/skills/svg-sprites/build.mjs --check", "check:skill": "node src/skills/svg-sprites/build.mjs --check",
"check:skills": "node scripts/check-skills.mjs && npm run check:skill",
"test:skills": "node --test test/skills.test.mjs",
"dev": "tsup --watch", "dev": "tsup --watch",
"test": "npm run build:package && node --test test/*.test.mjs", "test": "npm run build:package && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -67,7 +69,7 @@
"integration:build": "npm run build:package && npm run build --prefix integration", "integration:build": "npm run build:package && npm run build --prefix integration",
"integration:test": "npm run test:e2e --prefix integration", "integration:test": "npm run test:e2e --prefix integration",
"integration:verify": "npm run build:package && npm run verify --prefix integration", "integration:verify": "npm run build:package && npm run verify --prefix integration",
"verify": "npm run check:skill && npm run typecheck && npm test", "verify": "npm run check:skills && npm run typecheck && npm test",
"prepack": "npm run verify && npm run build" "prepack": "npm run verify && npm run build"
}, },
"keywords": [ "keywords": [
@@ -127,6 +129,7 @@
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"yaml": "2.9.0"
} }
} }
+76
View File
@@ -0,0 +1,76 @@
import { execFileSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from 'yaml'
export function checkSkills(root) {
// Проверяем Git-поставку и новые неигнорируемые файлы, включая скрытые каталоги.
const files = [...new Set(execFileSync('git', [
'-C', root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z',
], { encoding: 'utf8' }).split('\0').filter(Boolean))]
.filter((file) => existsSync(path.join(root, file)))
const entries = files.filter((file) => path.posix.basename(file).toLowerCase() === 'skill.md')
const errors = []
const names = new Map()
const publicEntry = /^skills\/([^/]+)\/SKILL\.md$/
if (!entries.some((file) => publicEntry.test(file))) errors.push('Не найдены опубликованные skills.')
const directories = new Set(files.filter((file) => file.startsWith('skills/') && file.split('/').length > 2)
.map((file) => file.split('/')[1]))
for (const directory of directories) {
const entry = `skills/${directory}/SKILL.md`
if (!entries.includes(entry)) errors.push(`Отсутствует точка входа: ${entry}`)
}
for (const file of entries.sort()) {
const location = file.match(publicEntry)
if (!location) errors.push(`SKILL.md вне skills/<имя>/: ${file}. Назовите исходник SKILL.source.md.`)
const absolute = path.join(root, file)
if (!lstatSync(absolute).isFile()) {
errors.push(`SKILL.md должен быть обычным файлом: ${file}`)
continue
}
const frontmatter = readFileSync(absolute, 'utf8').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
if (!frontmatter) {
errors.push(`Нет YAML-frontmatter: ${file}`)
continue
}
let metadata
try {
metadata = parse(frontmatter[1])
} catch (error) {
errors.push(`Некорректный YAML в ${file}: ${error.message}`)
continue
}
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
errors.push(`Frontmatter должен быть объектом: ${file}`)
continue
}
const { name, description } = metadata
if (typeof name !== 'string' || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
errors.push(`Некорректное name: ${file}`)
}
if (typeof description !== 'string' || !description.trim()) {
errors.push(`description должен быть непустой строкой: ${file}`)
}
if (typeof name === 'string') {
const key = name.toLowerCase().replace(/[\s_]+/g, '-')
if (names.has(key)) errors.push(`Повторное имя ${name}: ${names.get(key)} и ${file}`)
else names.set(key, file)
if (location && name !== location[1]) errors.push(`name не совпадает с каталогом: ${file}`)
}
}
if (errors.length) throw new Error(errors.join('\n'))
return [...names.keys()].sort()
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
try {
console.log(`Проверены skills: ${checkSkills(root).join(', ')}`)
} catch (error) {
console.error(error.message)
process.exitCode = 1
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ name: svg-sprites-ru
description: "Используй только при настройке, изменении или диагностике @gromlab/svg-sprites. Триггеры: @gromlab/svg-sprites, svg-sprite.config.json, defineSpriteConfig, generateSprite, standalone@server, source: remote, ServerSvgInput, exact modes для standalone, React, Next.js, Vue, Nuxt, Svelte, Angular, Astro, Solid, Preact, Qwik, Lit или Alpine.js, SpriteConfig.input, --input, SpriteViewer и --icon-color-N. НЕ используй для самописных SVG-спрайтов, inline SVG, favicon, растровых изображений, icon fonts или выбора библиотеки иконок." description: "Используй только при настройке, изменении или диагностике @gromlab/svg-sprites. Триггеры: @gromlab/svg-sprites, svg-sprite.config.json, defineSpriteConfig, generateSprite, standalone@server, source: remote, ServerSvgInput, exact modes для standalone, React, Next.js, Vue, Nuxt, Svelte, Angular, Astro, Solid, Preact, Qwik, Lit или Alpine.js, SpriteConfig.input, --input, SpriteViewer и --icon-color-N. НЕ используй для самописных SVG-спрайтов, inline SVG, favicon, растровых изображений, icon fonts или выбора библиотеки иконок."
--- ---
<!-- Generated from src/skills/svg-sprites/src/ru/SKILL.md. Do not edit manually. --> <!-- Generated from src/skills/svg-sprites/src/ru/SKILL.source.md. Do not edit manually. -->
# @gromlab/svg-sprites # @gromlab/svg-sprites
+1 -1
View File
@@ -3,7 +3,7 @@ name: svg-sprites
description: "Use only when configuring, generating, or troubleshooting @gromlab/svg-sprites. Triggers: @gromlab/svg-sprites, svg-sprite.config.json, defineSpriteConfig, generateSprite, standalone@server, source: remote, ServerSvgInput, exact modes for standalone, React, Next.js, Vue, Nuxt, Svelte, Angular, Astro, Solid, Preact, Qwik, Lit, or Alpine.js, SpriteConfig.input, --input, SpriteViewer, or --icon-color-N. Do NOT use for custom SVG sprites, favicons, raster images, icon fonts, choosing an icon set, or inline SVG without this package." description: "Use only when configuring, generating, or troubleshooting @gromlab/svg-sprites. Triggers: @gromlab/svg-sprites, svg-sprite.config.json, defineSpriteConfig, generateSprite, standalone@server, source: remote, ServerSvgInput, exact modes for standalone, React, Next.js, Vue, Nuxt, Svelte, Angular, Astro, Solid, Preact, Qwik, Lit, or Alpine.js, SpriteConfig.input, --input, SpriteViewer, or --icon-color-N. Do NOT use for custom SVG sprites, favicons, raster images, icon fonts, choosing an icon set, or inline SVG without this package."
--- ---
<!-- Generated from src/skills/svg-sprites/src/en/SKILL.md. Do not edit manually. --> <!-- Generated from src/skills/svg-sprites/src/en/SKILL.source.md. Do not edit manually. -->
# @gromlab/svg-sprites # @gromlab/svg-sprites
+8 -2
View File
@@ -6,12 +6,14 @@
```text ```text
src/{en,ru}/ src/{en,ru}/
├── SKILL.md ├── SKILL.source.md
└── references/ └── references/
└── complex-svg.md └── complex-svg.md
``` ```
Каждый `SKILL.md` содержит обязательные знания о пакете, рабочий процесс агента и operational map canonical-документации. Exact-mode настройка берётся из canonical guides, а не дублируется отдельными source-фрагментами. Agent-specific `complex-svg.md` остаётся отдельным reference. Каждый `SKILL.source.md` содержит обязательные знания о пакете, рабочий процесс агента и карту основной документации. Настройка конкретного mode берётся из его руководства. Документ `complex-svg.md` остаётся отдельным справочником для агента.
Имя `SKILL.md` зарезервировано для готовых skills в `skills/<имя>/`. Заготовки называются `SKILL.source.md` и не содержат frontmatter: сборщик добавляет его из `skill.config.mjs`. Это исключает обнаружение заготовок как отдельных skills при `npx skills update`.
Английский artifact дополнительно получает без изменений `README.md` и содержательную пользовательскую документацию из `docs/en/`; русский — `README_RU.md` и `docs/ru/`. Локальный редакторский `guides/AGENTS.md`, а также навигационные `guides/README.md` и `reference/README.md` не копируются. Canonical-файлы находятся в `references/README.md` и `references/docs/en/` либо в `references/README_RU.md` и `references/docs/ru/`. Английский artifact дополнительно получает без изменений `README.md` и содержательную пользовательскую документацию из `docs/en/`; русский — `README_RU.md` и `docs/ru/`. Локальный редакторский `guides/AGENTS.md`, а также навигационные `guides/README.md` и `reference/README.md` не копируются. Canonical-файлы находятся в `references/README.md` и `references/docs/en/` либо в `references/README_RU.md` и `references/docs/ru/`.
@@ -29,6 +31,10 @@ Include раскрываются рекурсивно, путь считаетс
```bash ```bash
npm run build:skill npm run build:skill
npm run check:skills
npm run test:skills
``` ```
Команда собирает и валидирует обе языковые версии, затем атомарно заменяет корневой каталог `skills/`. Сборщик проверяет точный список файлов, безопасные пути, symlink, Markdown fences, локальные ссылки и anchors, единственный H1, frontmatter, размер основного документа и отсутствие `TODO`. `npm run check:skill` дополнительно проверяет, что версионируемые артефакты совпадают с результатом сборки. Команда собирает и валидирует обе языковые версии, затем атомарно заменяет корневой каталог `skills/`. Сборщик проверяет точный список файлов, безопасные пути, symlink, Markdown fences, локальные ссылки и anchors, единственный H1, frontmatter, размер основного документа и отсутствие `TODO`. `npm run check:skill` дополнительно проверяет, что версионируемые артефакты совпадают с результатом сборки.
`check:skills` сначала проверяет всё Git-дерево и новые неигнорируемые файлы: `SKILL.md` разрешён только в `skills/<имя>/`, имя должно совпадать с каталогом и быть уникальным, а `name` и `description` — непустыми строками в корректном YAML. Затем выполняется существующая проверка `check:skill`. Общая проверка входит в `verify`, CI и выпуск пакета. `test:skills` отдельно запускает регрессионные тесты обнаружения skills; они также входят в основной набор тестов.
+1 -1
View File
@@ -9,7 +9,7 @@ const agentReferences = {
function documents(language) { function documents(language) {
return [ return [
{ entry: `src/${language}/SKILL.md`, to: 'SKILL.md', skill: true }, { entry: `src/${language}/SKILL.source.md`, to: 'SKILL.md', skill: true },
...agentReferences[language].map((file) => ({ ...agentReferences[language].map((file) => ({
entry: `src/${language}/references/${file}`, entry: `src/${language}/references/${file}`,
to: `references/${file}`, to: `references/${file}`,
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { checkSkills } from '../scripts/check-skills.mjs'
const valid = '---\nname: example\ndescription: >-\n Проверенный skill\n---\n# Example\n'
function repository(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-check-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const git = (...args) => execFileSync('git', ['-C', root, ...args], { stdio: 'pipe' })
git('init', '--quiet')
const write = (file, content) => {
const target = path.join(root, file)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, content)
}
write('skills/example/SKILL.md', valid)
return { root, write, git }
}
test('проверяет новый skill с YAML block scalar и отдельным исходником', (t) => {
const { root, write } = repository(t)
write('src/example/SKILL.source.md', valid)
assert.deepEqual(checkSkills(root), ['example'])
})
test('отклоняет одинаковые SKILL.md в исходниках и готовом каталоге', (t) => {
const { root, write } = repository(t)
write('src/skills/example/SKILL.md', valid)
assert.throws(() => checkSkills(root), /Повторное имя example/)
})
test('находит заготовку без frontmatter в скрытом вложенном каталоге', (t) => {
const { root, write } = repository(t)
write('.hidden/src/en/SKILL.md', '# Заготовка\n')
assert.throws(() => checkSkills(root), /Нет YAML-frontmatter: .hidden\/src\/en\/SKILL.md/)
})
for (const [title, metadata, error] of [
['нет name', 'description: example', /Некорректное name/],
['нет description', 'name: example', /description должен/],
['description не строка', 'name: example\ndescription: true', /description должен/],
['пустое description', 'name: example\ndescription: " "', /description должен/],
['ошибка YAML', 'name: [example\ndescription: example', /Некорректный YAML/],
['повтор поля YAML', 'name: example\nname: example\ndescription: example', /Некорректный YAML/],
['имя другого каталога', 'name: another\ndescription: example', /name не совпадает/],
]) {
test(`отклоняет frontmatter: ${title}`, (t) => {
const { root, write } = repository(t)
write('skills/example/SKILL.md', `---\n${metadata}\n---\n# Example\n`)
assert.throws(() => checkSkills(root), error)
})
}
test('игнорирует локальные зависимости, но обнаруживает их в Git-поставке', (t) => {
const { root, write, git } = repository(t)
write('.gitignore', '/.agents/skills/\n')
write('.agents/skills/example/SKILL.md', valid)
assert.deepEqual(checkSkills(root), ['example'])
git('add', '--force', '.agents/skills/example/SKILL.md')
assert.throws(() => checkSkills(root), /SKILL.md вне skills/)
})
test('отклоняет каталог skill без точки входа', (t) => {
const { root, write } = repository(t)
write('skills/incomplete/reference.md', '# Reference\n')
assert.throws(() => checkSkills(root), /Отсутствует точка входа: skills\/incomplete\/SKILL.md/)
})