@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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/command.d.mts +56 -0
- package/dist/command.d.mts.map +1 -0
- package/dist/command.mjs +135 -0
- package/dist/command.mjs.map +1 -0
- package/dist/errors.d.mts +13 -0
- package/dist/errors.d.mts.map +1 -0
- package/dist/errors.mjs +18 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/manifest.d.mts +33 -0
- package/dist/manifest.d.mts.map +1 -0
- package/dist/manifest.mjs +18 -0
- package/dist/manifest.mjs.map +1 -0
- package/dist/markdown.d.mts +17 -0
- package/dist/markdown.d.mts.map +1 -0
- package/dist/markdown.mjs +41 -0
- package/dist/markdown.mjs.map +1 -0
- package/dist/merge.d.mts +42 -0
- package/dist/merge.d.mts.map +1 -0
- package/dist/merge.mjs +32 -0
- package/dist/merge.mjs.map +1 -0
- package/dist/name.d.mts +10 -0
- package/dist/name.d.mts.map +1 -0
- package/dist/name.mjs +18 -0
- package/dist/name.mjs.map +1 -0
- package/dist/skill.d.mts +30 -0
- package/dist/skill.d.mts.map +1 -0
- package/dist/skill.mjs +47 -0
- package/dist/skill.mjs.map +1 -0
- package/package.json +54 -0
- package/src/command.ts +196 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +19 -0
- package/src/manifest.ts +16 -0
- package/src/markdown.ts +74 -0
- package/src/merge.ts +61 -0
- package/src/name.ts +28 -0
- package/src/skill.ts +72 -0
|
@@ -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"}
|
package/dist/skill.d.mts
ADDED
|
@@ -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'
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -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')
|