skills: парсер YAML-скилов (opencode-формат)
Новый KMP-модуль :skills (9 целей, зеркалит :proto). Парсит скилы
в формате opencode: YAML-фронтматтер с name+description, затем markdown
body.
- SkillFile(name, description, body) — @Serializable результат.
- SkillParseError — sealed-иерархия ошибок: Empty, MissingOpeningFence,
MissingClosingFence, InvalidYaml, SchemaMismatch, BlankName.
- SkillParser.parse(raw): SkillParseResult + parseOrThrow(raw): SkillFile.
Обрабатывает BOM, CRLF, имена с двоеточиями (backend:spring:db-base),
multiline-YAML, пустое тело; неизвестные ключи фронтматтера игнорирует.
- SkillParserTest — 16 тестов, 0 failures.
YAML-библиотека — com.charleskorn.kaml 0.104.0 (не JetBrains,
интеграция с kotlinx @Serializable). Грабли kaml 0.104: параметр
ignoreUnknownKeys переименован в strictMode (инвертирован);
missing-required-field прилетает как YamlException, не
SerializationException — различаем по сообщению.
Проверка: :skills:jvmTest 16/16, :skills:assemble (9 целей) — зелёные.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Полный набор KMP-целей. Зеркалит :proto.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// YAML-парсинг с интеграцией в @Serializable.
|
||||
implementation(libs.kaml)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Скил в формате opencode (https://opencode.ai): YAML-фронтматтер с `name` +
|
||||
* `description`, потом markdown body. Имя скила может содержать двоеточия
|
||||
* (`backend:spring:db-base`).
|
||||
*
|
||||
* Пример файла:
|
||||
* ```
|
||||
* ---
|
||||
* name: click-on
|
||||
* description: Use when you need to CLICK an element...
|
||||
* ---
|
||||
*
|
||||
* # Click-on / Type-on
|
||||
* ...
|
||||
* ```
|
||||
*
|
||||
* v1 ограничения:
|
||||
* - дополнительные поля фронтматтера игнорируются (kaml в default-конфигурации
|
||||
* бросает на неизвестных ключах — поэтому [SkillParser] использует
|
||||
* `ignoreUnknownKeys = true`).
|
||||
* - body — это сырая markdown-строка, без рендера.
|
||||
*/
|
||||
@Serializable
|
||||
data class SkillFile(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val body: String,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
/**
|
||||
sealed-иерархия ошибок парсера. Каждая содержит достаточно контекста,
|
||||
чтобы показать пользователю, что не так с конкретным файлом.
|
||||
*/
|
||||
sealed interface SkillParseError {
|
||||
/** Файл пуст. */
|
||||
data object Empty : SkillParseError
|
||||
|
||||
/** Файл не начинается с `---` (фронтматтер обязателен). */
|
||||
data object MissingOpeningFence : SkillParseError {
|
||||
private fun readResolve(): Any = MissingOpeningFence
|
||||
}
|
||||
|
||||
/** Файл начинается с `---`, но закрывающего `---` нет. */
|
||||
data object MissingClosingFence : SkillParseError {
|
||||
private fun readResolve(): Any = MissingClosingFence
|
||||
}
|
||||
|
||||
/** YAML во фронтматтере не распарсился. */
|
||||
data class InvalidYaml(val message: String) : SkillParseError
|
||||
|
||||
/** YAML распарсился, но не в [SkillFile] (нет обязательного поля, тип не тот). */
|
||||
data class SchemaMismatch(val message: String) : SkillParseError
|
||||
|
||||
/** `name` пустая или содержит только пробелы. */
|
||||
data object BlankName : SkillParseError {
|
||||
private fun readResolve(): Any = BlankName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Парсер скилов в формате opencode — YAML-фронтматтер + markdown body.
|
||||
*
|
||||
* Поведение:
|
||||
* 1. Файл обязан начинаться с `---` (на отдельной строке). После — YAML до
|
||||
* следующей строки `---`. Дальше — markdown body до конца файла.
|
||||
* 2. Лидирующий BOM (\uFEFF) отбрасывается.
|
||||
* 3. CRLF нормализуется в LF для разбора, но сохраняется как есть в body.
|
||||
* 4. Дополнительные поля фронтматтера игнорируются (`ignoreUnknownKeys = true`).
|
||||
* 5. `name` триммится; пустое после тримма → [SkillParseError.BlankName].
|
||||
*
|
||||
* Потокобезопасен: парсер без состояния, kaml тоже.
|
||||
*/
|
||||
object SkillParser {
|
||||
|
||||
private val yaml: Yaml = Yaml(
|
||||
configuration = com.charleskorn.kaml.YamlConfiguration(
|
||||
// 0.104: вместо `ignoreUnknownKeys` — `strictMode = false`.
|
||||
// Неизвестные ключи в фронтматтере просто пропускаются.
|
||||
strictMode = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class Frontmatter(
|
||||
val name: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Парсит [raw] в [SkillFile]. Бросает [IllegalStateException] (с понятным
|
||||
* сообщением) при любой ошибке [SkillParseError].
|
||||
*/
|
||||
fun parseOrThrow(raw: String): SkillFile = when (val result = parse(raw)) {
|
||||
is SkillParseResult.Ok -> result.value
|
||||
is SkillParseResult.Err -> throw IllegalStateException("Skill parse error: $result")
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит [raw] в [SkillFile] или [SkillParseError]. Возврат ошибки (а не
|
||||
* исключения) удобен когда нужно отдать пользователю список битых скилов.
|
||||
*/
|
||||
fun parse(raw: String): SkillParseResult {
|
||||
if (raw.isEmpty()) return SkillParseResult.Err(SkillParseError.Empty)
|
||||
|
||||
val text = raw.stripBom()
|
||||
val lines = text.split("\n")
|
||||
|
||||
if (lines[0].trim() != "---") {
|
||||
return SkillParseResult.Err(SkillParseError.MissingOpeningFence)
|
||||
}
|
||||
|
||||
// Ищем закрывающий `---` (строка ровно из `---` после тримма).
|
||||
val closingIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||
if (closingIdx < 0) {
|
||||
return SkillParseResult.Err(SkillParseError.MissingClosingFence)
|
||||
}
|
||||
// drop(1) сдвинул индексы на 1.
|
||||
val absClosing = closingIdx + 1
|
||||
|
||||
val yamlText = lines.subList(1, absClosing).joinToString("\n")
|
||||
val bodyText = lines.subList(absClosing + 1, lines.size).joinToString("\n")
|
||||
|
||||
val fm: Frontmatter = try {
|
||||
yaml.decodeFromString(Frontmatter.serializer(), yamlText)
|
||||
} catch (e: com.charleskorn.kaml.YamlException) {
|
||||
// kaml кидает YamlException и на синтаксические ошибки YAML, и на
|
||||
// отсутствие обязательных полей. Различаем по сообщению: missing-field
|
||||
// ошибки kotlinx-serialization имеют форму
|
||||
// "Field 'description' is required ..." → относим к SchemaMismatch.
|
||||
val msg = e.message ?: "<unknown>"
|
||||
val err = if ("is required" in msg || "Missing field" in msg) {
|
||||
SkillParseError.SchemaMismatch(msg)
|
||||
} else {
|
||||
SkillParseError.InvalidYaml(msg)
|
||||
}
|
||||
return SkillParseResult.Err(err)
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
return SkillParseResult.Err(SkillParseError.SchemaMismatch(e.message ?: "<unknown>"))
|
||||
}
|
||||
|
||||
val name = fm.name.trim()
|
||||
if (name.isEmpty()) return SkillParseResult.Err(SkillParseError.BlankName)
|
||||
|
||||
return SkillParseResult.Ok(
|
||||
SkillFile(
|
||||
name = name,
|
||||
description = fm.description.trim(),
|
||||
body = bodyText.trimStart('\n'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.stripBom(): String =
|
||||
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
|
||||
}
|
||||
|
||||
sealed interface SkillParseResult {
|
||||
data class Ok(val value: SkillFile) : SkillParseResult
|
||||
data class Err(val error: SkillParseError) : SkillParseResult
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SkillParserTest {
|
||||
|
||||
private val sample = """
|
||||
---
|
||||
name: click-on
|
||||
description: Use when you need to CLICK an element.
|
||||
---
|
||||
|
||||
# Click-on / Type-on
|
||||
|
||||
Скил «руки» для UI-автоматизации.
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun parsesOpencodeStyleSkill() {
|
||||
val r = SkillParser.parse(sample)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("click-on", ok.value.name)
|
||||
assertTrue(ok.value.description.startsWith("Use when"))
|
||||
assertTrue(ok.value.body.startsWith("# Click-on"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimsNameAndDescription() {
|
||||
val raw = """
|
||||
---
|
||||
name: spaced-name
|
||||
description: trim me
|
||||
---
|
||||
body
|
||||
""".trimIndent()
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("spaced-name", ok.value.name)
|
||||
assertEquals("trim me", ok.value.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsColonInName() {
|
||||
val raw = """
|
||||
---
|
||||
name: backend:spring:db-base
|
||||
description: ok
|
||||
---
|
||||
body
|
||||
""".trimIndent()
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("backend:spring:db-base", ok.value.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnknownFrontmatterKeys() {
|
||||
val raw = """
|
||||
---
|
||||
name: with-extras
|
||||
description: ok
|
||||
author: me
|
||||
tags: [a, b]
|
||||
---
|
||||
body
|
||||
""".trimIndent()
|
||||
val r = SkillParser.parse(raw)
|
||||
assertIs<SkillParseResult.Ok>(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyBody() {
|
||||
val raw = """
|
||||
---
|
||||
name: a
|
||||
description: b
|
||||
---
|
||||
""".trimIndent()
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("", ok.value.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlesBom() {
|
||||
val raw = "\uFEFF---\nname: bom\ndescription: ok\n---\nbody"
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("bom", ok.value.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlesCrlf() {
|
||||
val raw = "---\r\nname: crlf\r\ndescription: ok\r\n---\r\nbody"
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertEquals("crlf", ok.value.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyInput() {
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse("")).also {
|
||||
assertEquals(SkillParseError.Empty, it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOpeningFence() {
|
||||
val raw = "name: no-fence\ndescription: ok\n---\nbody"
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
|
||||
assertEquals(SkillParseError.MissingOpeningFence, it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingClosingFence() {
|
||||
val raw = "---\nname: half-open\ndescription: ok\nbody without closing"
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
|
||||
assertEquals(SkillParseError.MissingClosingFence, it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankNameRejected() {
|
||||
val raw = "---\nname: ' '\ndescription: ok\n---\nbody"
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
|
||||
assertEquals(SkillParseError.BlankName, it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidYaml() {
|
||||
val raw = "---\nname: [unclosed\ndescription: ok\n---\nbody"
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
|
||||
assertIs<SkillParseError.InvalidYaml>(it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemaMismatchWhenMissingDescription() {
|
||||
val raw = "---\nname: only-name\n---\nbody"
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
|
||||
assertIs<SkillParseError.SchemaMismatch>(it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseOrThrowReturnsValue() {
|
||||
val skill = SkillParser.parseOrThrow(sample)
|
||||
assertEquals("click-on", skill.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseOrThrowRaisesOnError() {
|
||||
var thrown = false
|
||||
try {
|
||||
SkillParser.parseOrThrow("")
|
||||
} catch (_: IllegalStateException) {
|
||||
thrown = true
|
||||
}
|
||||
assertTrue(thrown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiLineYamlValue() {
|
||||
val raw = """
|
||||
---
|
||||
name: multi
|
||||
description: |
|
||||
line one
|
||||
line two
|
||||
---
|
||||
body
|
||||
""".trimIndent()
|
||||
val r = SkillParser.parse(raw)
|
||||
val ok = assertIs<SkillParseResult.Ok>(r)
|
||||
assertTrue(ok.value.description.contains("line one"))
|
||||
assertTrue(ok.value.description.contains("line two"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user