@yolk-sdk/skillset 0.0.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"name.mjs","names":[],"sources":["../src/name.ts"],"sourcesContent":["import { Effect } from 'effect'\nimport { SkillsetError } from './errors.ts'\n\nconst skillsetNamePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/\nconst maxSkillsetNameLength = 64\n\nexport const isValidSkillsetName = (name: string) =>\n name.length > 0 && name.length <= maxSkillsetNameLength && skillsetNamePattern.test(name)\n\nexport const validateSkillsetName = (name: string) =>\n isValidSkillsetName(name)\n ? Effect.succeed(name)\n : Effect.fail(\n new SkillsetError({\n cause: 'invalid_name',\n message: `Invalid skillset entry name: ${name}`\n })\n )\n\nexport const validateDirectoryName = (expected: string, actual: string) =>\n expected === actual\n ? Effect.succeed(expected)\n : Effect.fail(\n new SkillsetError({\n cause: 'name_mismatch',\n message: `Skill name must match directory name: expected ${expected}, got ${actual}`\n })\n )\n"],"mappings":";;;AAGA,MAAM,sBAAsB;AAC5B,MAAM,wBAAwB;AAE9B,MAAa,uBAAuB,SAClC,KAAK,SAAS,KAAK,KAAK,UAAU,yBAAyB,oBAAoB,KAAK,IAAI;AAE1F,MAAa,wBAAwB,SACnC,oBAAoB,IAAI,IACpB,OAAO,QAAQ,IAAI,IACnB,OAAO,KACL,IAAI,cAAc;CAChB,OAAO;CACP,SAAS,gCAAgC;AAC3C,CAAC,CACH;AAEN,MAAa,yBAAyB,UAAkB,WACtD,aAAa,SACT,OAAO,QAAQ,QAAQ,IACvB,OAAO,KACL,IAAI,cAAc;CAChB,OAAO;CACP,SAAS,kDAAkD,SAAS,QAAQ;AAC9E,CAAC,CACH"}
@@ -0,0 +1,30 @@
1
+ import { SkillsetError } from "./errors.mjs";
2
+ import { Effect } from "effect";
3
+ import * as Schema from "effect/Schema";
4
+
5
+ //#region src/skill.d.ts
6
+ declare const SkillInfo: Schema.Struct<{
7
+ readonly name: Schema.String;
8
+ readonly description: Schema.String;
9
+ readonly location: Schema.String;
10
+ readonly content: Schema.String;
11
+ readonly source: Schema.optional<Schema.String>;
12
+ }>;
13
+ type SkillInfo = typeof SkillInfo.Type;
14
+ type ParseSkillInput = {
15
+ readonly markdown: string;
16
+ readonly location: string;
17
+ readonly directoryName?: string;
18
+ readonly source?: string;
19
+ };
20
+ declare const parseSkillMarkdown: (input: ParseSkillInput) => Effect.Effect<{
21
+ name: string;
22
+ description: string;
23
+ location: string;
24
+ content: string;
25
+ source: string | undefined;
26
+ }, SkillsetError, never>;
27
+ declare const formatAvailableSkills: (skills: ReadonlyArray<SkillInfo>) => string;
28
+ //#endregion
29
+ export { ParseSkillInput, SkillInfo, formatAvailableSkills, parseSkillMarkdown };
30
+ //# sourceMappingURL=skill.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.d.mts","names":[],"sources":["../src/skill.ts"],"mappings":";;;;;cAMa,SAAA,EAAS,MAAA,CAAA,MAAA;EAAA;;;;;;KAOV,SAAA,UAAmB,SAAA,CAAU,IAAI;AAAA,KAEjC,eAAA;EAAA,SACD,QAAA;EAAA,SACA,QAAA;EAAA,SACA,aAAA;EAAA,SACA,MAAA;AAAA;AAAA,cAgBE,kBAAA,GAAsB,KAAA,EAAO,eAAA,KAAe,MAAA,CAAA,MAAA;;;;;;;cAqB5C,qBAAA,GAAyB,MAAA,EAAQ,aAAa,CAAC,SAAA"}
package/dist/skill.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import { SkillsetError } from "./errors.mjs";
2
+ import { parseMarkdownDocument } from "./markdown.mjs";
3
+ import { validateDirectoryName, validateSkillsetName } from "./name.mjs";
4
+ import { Effect } from "effect";
5
+ import * as Schema from "effect/Schema";
6
+ //#region src/skill.ts
7
+ const SkillInfo = Schema.Struct({
8
+ name: Schema.String,
9
+ description: Schema.String,
10
+ location: Schema.String,
11
+ content: Schema.String,
12
+ source: Schema.optional(Schema.String)
13
+ });
14
+ const requiredField = (data, field) => {
15
+ const value = data[field];
16
+ return value === void 0 || value.length === 0 ? Effect.fail(new SkillsetError({
17
+ cause: "frontmatter_field_missing",
18
+ message: `Skill frontmatter requires ${field}`
19
+ })) : Effect.succeed(value);
20
+ };
21
+ const parseSkillMarkdown = (input) => Effect.gen(function* () {
22
+ const document = yield* parseMarkdownDocument(input.markdown);
23
+ const name = yield* requiredField(document.data, "name").pipe(Effect.flatMap(validateSkillsetName));
24
+ const description = yield* requiredField(document.data, "description");
25
+ if (input.directoryName !== void 0) yield* validateDirectoryName(input.directoryName, name);
26
+ return {
27
+ name,
28
+ description,
29
+ location: input.location,
30
+ content: document.content,
31
+ source: input.source
32
+ };
33
+ });
34
+ const formatAvailableSkills = (skills) => skills.length === 0 ? "" : [
35
+ "<available_skills>",
36
+ ...skills.slice().sort((a, b) => a.name.localeCompare(b.name)).flatMap((skill) => [
37
+ " <skill>",
38
+ ` <name>${skill.name}</name>`,
39
+ ` <description>${skill.description}</description>`,
40
+ " </skill>"
41
+ ]),
42
+ "</available_skills>"
43
+ ].join("\n");
44
+ //#endregion
45
+ export { SkillInfo, formatAvailableSkills, parseSkillMarkdown };
46
+
47
+ //# sourceMappingURL=skill.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.mjs","names":[],"sources":["../src/skill.ts"],"sourcesContent":["import { Effect } from 'effect'\nimport * as Schema from 'effect/Schema'\nimport { SkillsetError } from './errors.ts'\nimport { parseMarkdownDocument } from './markdown.ts'\nimport { validateDirectoryName, validateSkillsetName } from './name.ts'\n\nexport const SkillInfo = Schema.Struct({\n name: Schema.String,\n description: Schema.String,\n location: Schema.String,\n content: Schema.String,\n source: Schema.optional(Schema.String)\n})\nexport type SkillInfo = typeof SkillInfo.Type\n\nexport type ParseSkillInput = {\n readonly markdown: string\n readonly location: string\n readonly directoryName?: string\n readonly source?: string\n}\n\nconst requiredField = (data: Readonly<Record<string, string>>, field: string) => {\n const value = data[field]\n\n return value === undefined || value.length === 0\n ? Effect.fail(\n new SkillsetError({\n cause: 'frontmatter_field_missing',\n message: `Skill frontmatter requires ${field}`\n })\n )\n : Effect.succeed(value)\n}\n\nexport const parseSkillMarkdown = (input: ParseSkillInput) =>\n Effect.gen(function* () {\n const document = yield* parseMarkdownDocument(input.markdown)\n const name = yield* requiredField(document.data, 'name').pipe(\n Effect.flatMap(validateSkillsetName)\n )\n const description = yield* requiredField(document.data, 'description')\n\n if (input.directoryName !== undefined) {\n yield* validateDirectoryName(input.directoryName, name)\n }\n\n return {\n name,\n description,\n location: input.location,\n content: document.content,\n source: input.source\n }\n })\n\nexport const formatAvailableSkills = (skills: ReadonlyArray<SkillInfo>) =>\n skills.length === 0\n ? ''\n : [\n '<available_skills>',\n ...skills\n .slice()\n .sort((a, b) => a.name.localeCompare(b.name))\n .flatMap(skill => [\n ' <skill>',\n ` <name>${skill.name}</name>`,\n ` <description>${skill.description}</description>`,\n ' </skill>'\n ]),\n '</available_skills>'\n ].join('\\n')\n"],"mappings":";;;;;;AAMA,MAAa,YAAY,OAAO,OAAO;CACrC,MAAM,OAAO;CACb,aAAa,OAAO;CACpB,UAAU,OAAO;CACjB,SAAS,OAAO;CAChB,QAAQ,OAAO,SAAS,OAAO,MAAM;AACvC,CAAC;AAUD,MAAM,iBAAiB,MAAwC,UAAkB;CAC/E,MAAM,QAAQ,KAAK;CAEnB,OAAO,UAAU,KAAA,KAAa,MAAM,WAAW,IAC3C,OAAO,KACL,IAAI,cAAc;EAChB,OAAO;EACP,SAAS,8BAA8B;CACzC,CAAC,CACH,IACA,OAAO,QAAQ,KAAK;AAC1B;AAEA,MAAa,sBAAsB,UACjC,OAAO,IAAI,aAAa;CACtB,MAAM,WAAW,OAAO,sBAAsB,MAAM,QAAQ;CAC5D,MAAM,OAAO,OAAO,cAAc,SAAS,MAAM,MAAM,EAAE,KACvD,OAAO,QAAQ,oBAAoB,CACrC;CACA,MAAM,cAAc,OAAO,cAAc,SAAS,MAAM,aAAa;CAErE,IAAI,MAAM,kBAAkB,KAAA,GAC1B,OAAO,sBAAsB,MAAM,eAAe,IAAI;CAGxD,OAAO;EACL;EACA;EACA,UAAU,MAAM;EAChB,SAAS,SAAS;EAClB,QAAQ,MAAM;CAChB;AACF,CAAC;AAEH,MAAa,yBAAyB,WACpC,OAAO,WAAW,IACd,KACA;CACE;CACA,GAAG,OACA,MAAM,EACN,MAAM,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC,EAC3C,SAAQ,UAAS;EAChB;EACA,aAAa,MAAM,KAAK;EACxB,oBAAoB,MAAM,YAAY;EACtC;CACF,CAAC;CACH;AACF,EAAE,KAAK,IAAI"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@yolk-sdk/skillset",
3
+ "version": "0.0.1-canary.0",
4
+ "description": "Portable skill and slash-command parsing and catalog primitives for Yolk.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/magoz/yolk-sdk.git",
11
+ "directory": "packages/skillset"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/magoz/yolk-sdk/issues"
15
+ },
16
+ "homepage": "https://github.com/magoz/yolk-sdk#readme",
17
+ "keywords": [
18
+ "skills",
19
+ "commands",
20
+ "agents",
21
+ "effect"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "exports": {
27
+ "./package.json": "./package.json",
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs",
31
+ "default": "./dist/index.mjs"
32
+ }
33
+ },
34
+ "files": [
35
+ "src/**/*.ts",
36
+ "!src/**/*.test.ts",
37
+ "!src/**/*.test.tsx",
38
+ "dist/**/*",
39
+ "README.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "provenance": true
44
+ },
45
+ "dependencies": {
46
+ "effect": "4.0.0-beta.65"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "check": "tsc -p tsconfig.json --noEmit",
51
+ "test": "vitest run --passWithNoTests",
52
+ "test:run": "vitest run --passWithNoTests"
53
+ }
54
+ }
package/src/command.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { Effect } from 'effect'
2
+ import * as Schema from 'effect/Schema'
3
+ import { SkillsetError } from './errors.ts'
4
+ import { parseMarkdownDocument } from './markdown.ts'
5
+ import { validateSkillsetName } from './name.ts'
6
+
7
+ export const CommandArgument = Schema.Struct({
8
+ name: Schema.String,
9
+ required: Schema.Boolean,
10
+ description: Schema.optional(Schema.String)
11
+ })
12
+ export type CommandArgument = typeof CommandArgument.Type
13
+
14
+ export const CommandAccess = Schema.Union([
15
+ Schema.Literal('read'),
16
+ Schema.Literal('write'),
17
+ Schema.Literal('destructive')
18
+ ])
19
+ export type CommandAccess = typeof CommandAccess.Type
20
+
21
+ export const CommandInfo = Schema.Struct({
22
+ name: Schema.String,
23
+ description: Schema.optional(Schema.String),
24
+ template: Schema.String,
25
+ hints: Schema.Array(Schema.String),
26
+ arguments: Schema.optional(Schema.Array(CommandArgument)),
27
+ access: Schema.optional(CommandAccess),
28
+ fileRefs: Schema.optional(Schema.Boolean),
29
+ location: Schema.optional(Schema.String),
30
+ source: Schema.optional(Schema.String)
31
+ })
32
+ export type CommandInfo = typeof CommandInfo.Type
33
+
34
+ export type ParseCommandInput = {
35
+ readonly markdown: string
36
+ readonly name: string
37
+ readonly location?: string
38
+ readonly source?: string
39
+ }
40
+
41
+ const numberedPlaceholderPattern = /\$(\d+)/g
42
+
43
+ const parseCommandAccess = (value: string | undefined) => {
44
+ if (value === undefined || value.length === 0) {
45
+ return Effect.succeed(undefined)
46
+ }
47
+
48
+ switch (value) {
49
+ case 'read':
50
+ case 'write':
51
+ case 'destructive':
52
+ return Effect.succeed(value)
53
+ default:
54
+ return Effect.fail(
55
+ new SkillsetError({
56
+ cause: 'frontmatter_invalid',
57
+ message: 'Command access must be read, write, or destructive'
58
+ })
59
+ )
60
+ }
61
+ }
62
+
63
+ const parseBooleanField = (field: string, value: string | undefined) => {
64
+ if (value === undefined || value.length === 0) {
65
+ return Effect.succeed(undefined)
66
+ }
67
+
68
+ switch (value) {
69
+ case 'true':
70
+ return Effect.succeed(true)
71
+ case 'false':
72
+ return Effect.succeed(false)
73
+ default:
74
+ return Effect.fail(
75
+ new SkillsetError({
76
+ cause: 'frontmatter_invalid',
77
+ message: `${field} must be true or false`
78
+ })
79
+ )
80
+ }
81
+ }
82
+
83
+ const parseArgumentToken = (token: string) => {
84
+ const trimmed = token.trim()
85
+
86
+ if (trimmed.length === 0) {
87
+ return undefined
88
+ }
89
+
90
+ const required = !trimmed.endsWith('?')
91
+ const name = required ? trimmed : trimmed.slice(0, -1)
92
+
93
+ return name.length === 0 ? undefined : { name, required }
94
+ }
95
+
96
+ const parseCommandArgumentsField = (value: string | undefined): ReadonlyArray<CommandArgument> => {
97
+ if (value === undefined || value.length === 0) {
98
+ return []
99
+ }
100
+
101
+ return value.split(',').flatMap(token => {
102
+ const argument = parseArgumentToken(token)
103
+
104
+ return argument === undefined ? [] : [argument]
105
+ })
106
+ }
107
+
108
+ export const commandHints = (template: string) => {
109
+ const numbered = Array.from(
110
+ template.matchAll(numberedPlaceholderPattern),
111
+ match => `$${match[1]}`
112
+ )
113
+ const unique = [...new Set(numbered)].sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)))
114
+
115
+ return template.includes('$ARGUMENTS') ? [...unique, '$ARGUMENTS'] : unique
116
+ }
117
+
118
+ export const parseCommandMarkdown = (input: ParseCommandInput) =>
119
+ Effect.gen(function* () {
120
+ const name = yield* validateSkillsetName(input.name)
121
+ const document = yield* parseMarkdownDocument(input.markdown)
122
+ const description = document.data.description
123
+ const access = yield* parseCommandAccess(document.data.access)
124
+ const fileRefs = yield* parseBooleanField('fileRefs', document.data.fileRefs)
125
+ const commandArguments = parseCommandArgumentsField(document.data.arguments)
126
+
127
+ return {
128
+ name,
129
+ description: description === undefined || description.length === 0 ? undefined : description,
130
+ template: document.content,
131
+ hints: commandHints(document.content),
132
+ ...(commandArguments.length === 0 ? {} : { arguments: commandArguments }),
133
+ ...(access === undefined ? {} : { access }),
134
+ ...(fileRefs === undefined ? {} : { fileRefs }),
135
+ location: input.location,
136
+ source: input.source
137
+ }
138
+ })
139
+
140
+ export const parseCommandArguments = (input: string) => {
141
+ const result: string[] = []
142
+ let current = ''
143
+ let quote: 'single' | 'double' | undefined
144
+
145
+ for (const char of input.trim()) {
146
+ if (char === "'" && quote !== 'double') {
147
+ quote = quote === 'single' ? undefined : 'single'
148
+ continue
149
+ }
150
+
151
+ if (char === '"' && quote !== 'single') {
152
+ quote = quote === 'double' ? undefined : 'double'
153
+ continue
154
+ }
155
+
156
+ if (/\s/.test(char) && quote === undefined) {
157
+ if (current.length > 0) {
158
+ result.push(current)
159
+ current = ''
160
+ }
161
+ continue
162
+ }
163
+
164
+ current = `${current}${char}`
165
+ }
166
+
167
+ if (current.length > 0) {
168
+ result.push(current)
169
+ }
170
+
171
+ return result
172
+ }
173
+
174
+ export const renderCommand = (command: CommandInfo, argumentsText: string) => {
175
+ const args = parseCommandArguments(argumentsText)
176
+ const placeholders = Array.from(command.template.matchAll(numberedPlaceholderPattern), match =>
177
+ Number(match[1])
178
+ )
179
+ const lastPlaceholder = placeholders.reduce((max, value) => Math.max(max, value), 0)
180
+ const withNumbered = command.template.replace(numberedPlaceholderPattern, (_, index: string) => {
181
+ const position = Number(index)
182
+ const argIndex = position - 1
183
+
184
+ if (argIndex >= args.length) {
185
+ return ''
186
+ }
187
+
188
+ return position === lastPlaceholder ? args.slice(argIndex).join(' ') : (args[argIndex] ?? '')
189
+ })
190
+ const usesArguments = command.template.includes('$ARGUMENTS')
191
+ const rendered = withNumbered.replaceAll('$ARGUMENTS', argumentsText)
192
+
193
+ return placeholders.length === 0 && !usesArguments && argumentsText.trim().length > 0
194
+ ? `${rendered}\n\n${argumentsText}`.trim()
195
+ : rendered.trim()
196
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,16 @@
1
+ import * as Schema from 'effect/Schema'
2
+
3
+ export const SkillsetErrorCause = Schema.Literals([
4
+ 'duplicate_entry',
5
+ 'frontmatter_field_missing',
6
+ 'frontmatter_invalid',
7
+ 'frontmatter_missing',
8
+ 'invalid_name',
9
+ 'name_mismatch'
10
+ ])
11
+ export type SkillsetErrorCause = typeof SkillsetErrorCause.Type
12
+
13
+ export class SkillsetError extends Schema.TaggedErrorClass<SkillsetError>()('SkillsetError', {
14
+ cause: SkillsetErrorCause,
15
+ message: Schema.String
16
+ }) {}
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { SkillsetError, SkillsetErrorCause } from './errors.ts'
2
+ export { isValidSkillsetName, validateDirectoryName, validateSkillsetName } from './name.ts'
3
+ export { parseMarkdownDocument } from './markdown.ts'
4
+ export type { MarkdownDocument } from './markdown.ts'
5
+ export { SkillInfo, formatAvailableSkills, parseSkillMarkdown } from './skill.ts'
6
+ export {
7
+ CommandArgument,
8
+ CommandAccess,
9
+ CommandInfo,
10
+ commandHints,
11
+ parseCommandArguments,
12
+ parseCommandMarkdown,
13
+ renderCommand
14
+ } from './command.ts'
15
+ export { SkillsetManifest, emptySkillsetManifest } from './manifest.ts'
16
+ export { mergeSkillsets } from './merge.ts'
17
+ export type { MergedSkillset, SkillsetSource } from './merge.ts'
18
+ export type { ParseSkillInput } from './skill.ts'
19
+ export type { ParseCommandInput } from './command.ts'
@@ -0,0 +1,16 @@
1
+ import * as Schema from 'effect/Schema'
2
+ import { CommandInfo } from './command.ts'
3
+ import { SkillInfo } from './skill.ts'
4
+
5
+ export const SkillsetManifest = Schema.Struct({
6
+ version: Schema.Literal(1),
7
+ skills: Schema.Array(SkillInfo),
8
+ commands: Schema.Array(CommandInfo)
9
+ })
10
+ export type SkillsetManifest = typeof SkillsetManifest.Type
11
+
12
+ export const emptySkillsetManifest: SkillsetManifest = {
13
+ version: 1,
14
+ skills: [],
15
+ commands: []
16
+ }
@@ -0,0 +1,74 @@
1
+ import { Effect } from 'effect'
2
+ import { SkillsetError } from './errors.ts'
3
+
4
+ export type MarkdownDocument = {
5
+ readonly data: Readonly<Record<string, string>>
6
+ readonly content: string
7
+ }
8
+
9
+ const frontmatterStart = '---\n'
10
+
11
+ const frontmatterEndIndex = (markdown: string) => markdown.indexOf('\n---', frontmatterStart.length)
12
+
13
+ const parseFrontmatterLine = (line: string) => {
14
+ const separatorIndex = line.indexOf(':')
15
+
16
+ if (separatorIndex === -1) {
17
+ return undefined
18
+ }
19
+
20
+ const key = line.slice(0, separatorIndex).trim()
21
+ const value = line.slice(separatorIndex + 1).trim()
22
+
23
+ return key.length === 0 ? undefined : { key, value }
24
+ }
25
+
26
+ export const parseMarkdownDocument = (markdown: string) =>
27
+ Effect.gen(function* () {
28
+ if (!markdown.startsWith(frontmatterStart)) {
29
+ return yield* Effect.fail(
30
+ new SkillsetError({
31
+ cause: 'frontmatter_missing',
32
+ message: 'Markdown must start with YAML frontmatter'
33
+ })
34
+ )
35
+ }
36
+
37
+ const endIndex = frontmatterEndIndex(markdown)
38
+
39
+ if (endIndex === -1) {
40
+ return yield* Effect.fail(
41
+ new SkillsetError({
42
+ cause: 'frontmatter_invalid',
43
+ message: 'Markdown frontmatter is missing closing delimiter'
44
+ })
45
+ )
46
+ }
47
+
48
+ const entries = markdown
49
+ .slice(frontmatterStart.length, endIndex)
50
+ .split('\n')
51
+ .filter(line => line.trim().length > 0)
52
+ .map(parseFrontmatterLine)
53
+
54
+ if (entries.some(entry => entry === undefined)) {
55
+ return yield* Effect.fail(
56
+ new SkillsetError({
57
+ cause: 'frontmatter_invalid',
58
+ message: 'Markdown frontmatter only supports simple key: value fields'
59
+ })
60
+ )
61
+ }
62
+
63
+ const data = Object.fromEntries(
64
+ entries.flatMap(entry => (entry === undefined ? [] : [[entry.key, entry.value]]))
65
+ )
66
+ const contentStart = markdown.startsWith('\n', endIndex + frontmatterStart.length)
67
+ ? endIndex + frontmatterStart.length + 1
68
+ : endIndex + frontmatterStart.length
69
+
70
+ return {
71
+ data,
72
+ content: markdown.slice(contentStart).trim()
73
+ }
74
+ })
package/src/merge.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { Effect } from 'effect'
2
+ import { SkillsetError } from './errors.ts'
3
+ import type { CommandInfo } from './command.ts'
4
+ import type { SkillInfo } from './skill.ts'
5
+ import type { SkillsetManifest } from './manifest.ts'
6
+
7
+ export type SkillsetSource = {
8
+ readonly id: string
9
+ readonly manifest: SkillsetManifest
10
+ }
11
+
12
+ export type MergedSkillset = {
13
+ readonly skills: ReadonlyArray<SkillInfo>
14
+ readonly commands: ReadonlyArray<CommandInfo>
15
+ }
16
+
17
+ const mergeByName = <Entry extends { readonly name: string }>(
18
+ entries: ReadonlyArray<ReadonlyArray<Entry>>
19
+ ) => {
20
+ const byName = new Map<string, Entry>()
21
+
22
+ const reversedEntries = [...entries].reverse()
23
+
24
+ reversedEntries.forEach(group => {
25
+ group.forEach(entry => byName.set(entry.name, entry))
26
+ })
27
+
28
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
29
+ }
30
+
31
+ const duplicateNames = <Entry extends { readonly name: string }>(entries: ReadonlyArray<Entry>) => {
32
+ const names = entries.map(entry => entry.name)
33
+
34
+ return [...new Set(names.filter((name, index) => names.indexOf(name) !== index))]
35
+ }
36
+
37
+ const rejectInternalDuplicates = (source: SkillsetSource) => {
38
+ const duplicates = [
39
+ ...duplicateNames(source.manifest.skills).map(name => `skill:${name}`),
40
+ ...duplicateNames(source.manifest.commands).map(name => `command:${name}`)
41
+ ]
42
+
43
+ return duplicates.length === 0
44
+ ? Effect.void
45
+ : Effect.fail(
46
+ new SkillsetError({
47
+ cause: 'duplicate_entry',
48
+ message: `Duplicate entries in source ${source.id}: ${duplicates.join(', ')}`
49
+ })
50
+ )
51
+ }
52
+
53
+ export const mergeSkillsets = (sources: ReadonlyArray<SkillsetSource>) =>
54
+ Effect.gen(function* () {
55
+ yield* Effect.forEach(sources, rejectInternalDuplicates, { discard: true })
56
+
57
+ return {
58
+ skills: mergeByName(sources.map(source => source.manifest.skills)),
59
+ commands: mergeByName(sources.map(source => source.manifest.commands))
60
+ }
61
+ })
package/src/name.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Effect } from 'effect'
2
+ import { SkillsetError } from './errors.ts'
3
+
4
+ const skillsetNamePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/
5
+ const maxSkillsetNameLength = 64
6
+
7
+ export const isValidSkillsetName = (name: string) =>
8
+ name.length > 0 && name.length <= maxSkillsetNameLength && skillsetNamePattern.test(name)
9
+
10
+ export const validateSkillsetName = (name: string) =>
11
+ isValidSkillsetName(name)
12
+ ? Effect.succeed(name)
13
+ : Effect.fail(
14
+ new SkillsetError({
15
+ cause: 'invalid_name',
16
+ message: `Invalid skillset entry name: ${name}`
17
+ })
18
+ )
19
+
20
+ export const validateDirectoryName = (expected: string, actual: string) =>
21
+ expected === actual
22
+ ? Effect.succeed(expected)
23
+ : Effect.fail(
24
+ new SkillsetError({
25
+ cause: 'name_mismatch',
26
+ message: `Skill name must match directory name: expected ${expected}, got ${actual}`
27
+ })
28
+ )
package/src/skill.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Effect } from 'effect'
2
+ import * as Schema from 'effect/Schema'
3
+ import { SkillsetError } from './errors.ts'
4
+ import { parseMarkdownDocument } from './markdown.ts'
5
+ import { validateDirectoryName, validateSkillsetName } from './name.ts'
6
+
7
+ export const SkillInfo = Schema.Struct({
8
+ name: Schema.String,
9
+ description: Schema.String,
10
+ location: Schema.String,
11
+ content: Schema.String,
12
+ source: Schema.optional(Schema.String)
13
+ })
14
+ export type SkillInfo = typeof SkillInfo.Type
15
+
16
+ export type ParseSkillInput = {
17
+ readonly markdown: string
18
+ readonly location: string
19
+ readonly directoryName?: string
20
+ readonly source?: string
21
+ }
22
+
23
+ const requiredField = (data: Readonly<Record<string, string>>, field: string) => {
24
+ const value = data[field]
25
+
26
+ return value === undefined || value.length === 0
27
+ ? Effect.fail(
28
+ new SkillsetError({
29
+ cause: 'frontmatter_field_missing',
30
+ message: `Skill frontmatter requires ${field}`
31
+ })
32
+ )
33
+ : Effect.succeed(value)
34
+ }
35
+
36
+ export const parseSkillMarkdown = (input: ParseSkillInput) =>
37
+ Effect.gen(function* () {
38
+ const document = yield* parseMarkdownDocument(input.markdown)
39
+ const name = yield* requiredField(document.data, 'name').pipe(
40
+ Effect.flatMap(validateSkillsetName)
41
+ )
42
+ const description = yield* requiredField(document.data, 'description')
43
+
44
+ if (input.directoryName !== undefined) {
45
+ yield* validateDirectoryName(input.directoryName, name)
46
+ }
47
+
48
+ return {
49
+ name,
50
+ description,
51
+ location: input.location,
52
+ content: document.content,
53
+ source: input.source
54
+ }
55
+ })
56
+
57
+ export const formatAvailableSkills = (skills: ReadonlyArray<SkillInfo>) =>
58
+ skills.length === 0
59
+ ? ''
60
+ : [
61
+ '<available_skills>',
62
+ ...skills
63
+ .slice()
64
+ .sort((a, b) => a.name.localeCompare(b.name))
65
+ .flatMap(skill => [
66
+ ' <skill>',
67
+ ` <name>${skill.name}</name>`,
68
+ ` <description>${skill.description}</description>`,
69
+ ' </skill>'
70
+ ]),
71
+ '</available_skills>'
72
+ ].join('\n')