@namzu/sdk 15.0.0 → 16.0.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/CHANGELOG.md +151 -0
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +1 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +70 -112
- package/dist/skills/loader.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +90 -0
- package/dist/utils/frontmatter.d.ts.map +1 -0
- package/dist/utils/frontmatter.js +212 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/package.json +1 -1
- package/src/public-runtime.ts +5 -0
- package/src/public-types.ts +1 -0
- package/src/skills/loader.ts +67 -119
- package/src/utils/frontmatter.ts +265 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frontmatter reader.
|
|
3
|
+
*
|
|
4
|
+
* A `SKILL.md`, a command file, and anything else this kernel reads from a
|
|
5
|
+
* markdown file with a `---` fence all come through here. There were three
|
|
6
|
+
* readers before this one and two of them disagreed on the same input: this
|
|
7
|
+
* one **threw** on malformed frontmatter, and a second **silently returned no
|
|
8
|
+
* metadata**, so the same file was a hard error in one code path and a skill
|
|
9
|
+
* named after its own directory with "(no description)" in the other. Refuse
|
|
10
|
+
* versus degrade, on one file shape, is the divergence this module exists to
|
|
11
|
+
* end — see `docs/conventions/refuse-do-not-degrade.md`.
|
|
12
|
+
*
|
|
13
|
+
* **Deliberately not a YAML parser.** It is a flat key/value splitter with one
|
|
14
|
+
* level of nesting, and it refuses the constructs in {@link UNSUPPORTED_YAML}
|
|
15
|
+
* rather than mangling them. That refusal is the design: a reader that
|
|
16
|
+
* half-understands YAML produces a value that passes validation and means
|
|
17
|
+
* nothing.
|
|
18
|
+
*
|
|
19
|
+
* That refusal is now total for lists. A *block* sequence
|
|
20
|
+
*
|
|
21
|
+
* ```yaml
|
|
22
|
+
* allowed-tools:
|
|
23
|
+
* - Read
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* used to be silently dropped — its lines carry no `:` and were skipped, so the
|
|
27
|
+
* key came back **absent** — while the flow form `[Read, Grep]` threw. One
|
|
28
|
+
* spelling of a list was a hard error and the other was silence, and the silent
|
|
29
|
+
* one is the shape an author actually writes, because the block form is the
|
|
30
|
+
* natural YAML for a list.
|
|
31
|
+
*
|
|
32
|
+
* It matters most for a key like `allowed-tools`: a skill that asked for `Bash`
|
|
33
|
+
* and silently did not get it is indistinguishable from one that never asked,
|
|
34
|
+
* which is a capability quietly not granted rather than a formatting nicety.
|
|
35
|
+
* Both readers this replaced behaved that way, so it was inherited rather than
|
|
36
|
+
* introduced; it is now refused, naming the key.
|
|
37
|
+
*
|
|
38
|
+
* **Vocabulary belongs to the caller.** This returns the parsed map; it does
|
|
39
|
+
* not know what a skill needs or what a command needs, and it validates no
|
|
40
|
+
* field names. Widening one caller's metadata type to cover another's is how a
|
|
41
|
+
* skill-shaped API comes to mean something it does not.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* What one frontmatter key holds: a scalar, or a block of indented pairs.
|
|
45
|
+
*
|
|
46
|
+
* A discriminated union rather than two parallel maps, because the source
|
|
47
|
+
* format cannot express both at once. The first shape of this type had
|
|
48
|
+
* `data: Record<string, string>` beside `blocks: Record<string, Record<…>>`,
|
|
49
|
+
* which let one key sit in both — a state no YAML file can produce. Every
|
|
50
|
+
* caller would then have had to decide a precedence for a case that cannot
|
|
51
|
+
* arrive, and the ones who did not would be carrying a latent bug against a
|
|
52
|
+
* shape that told them the case existed. Removing the state beats documenting
|
|
53
|
+
* it.
|
|
54
|
+
*/
|
|
55
|
+
export type FrontmatterValue = {
|
|
56
|
+
readonly kind: 'scalar';
|
|
57
|
+
readonly value: string;
|
|
58
|
+
} | {
|
|
59
|
+
readonly kind: 'mapping';
|
|
60
|
+
readonly entries: Readonly<Record<string, string>>;
|
|
61
|
+
};
|
|
62
|
+
export interface ParsedFrontmatter {
|
|
63
|
+
/**
|
|
64
|
+
* Every top-level key, in the order the file declared it.
|
|
65
|
+
*
|
|
66
|
+
* A key whose value is empty and which has no indented lines under it is
|
|
67
|
+
* absent: it declared nothing. Narrow on `kind` to read it —
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const d = values.description
|
|
71
|
+
* if (d?.kind !== 'scalar') throw new Error('description must be a scalar')
|
|
72
|
+
* use(d.value)
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
readonly values: Readonly<Record<string, FrontmatterValue>>;
|
|
76
|
+
/** Everything after the closing fence, trimmed. */
|
|
77
|
+
readonly body: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Parse a markdown file's `---` frontmatter.
|
|
81
|
+
*
|
|
82
|
+
* @param raw The file's full contents. LF and CRLF both parse.
|
|
83
|
+
* @param source A label for error messages — a path, or a phrase naming the
|
|
84
|
+
* file. Used verbatim, so the caller controls how its own errors read.
|
|
85
|
+
* @throws If the frontmatter is absent, unclosed, or uses YAML this reader
|
|
86
|
+
* does not implement. It never returns a partial or empty result to stand in
|
|
87
|
+
* for a file it could not read.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseFrontmatter(raw: string, source: string): ParsedFrontmatter;
|
|
90
|
+
//# sourceMappingURL=frontmatter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/utils/frontmatter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AA8DH;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,gBAAgB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,CAAA;AAEnF,MAAM,WAAW,iBAAiB;IACjC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAA;IAE3D,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAoG/E"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frontmatter reader.
|
|
3
|
+
*
|
|
4
|
+
* A `SKILL.md`, a command file, and anything else this kernel reads from a
|
|
5
|
+
* markdown file with a `---` fence all come through here. There were three
|
|
6
|
+
* readers before this one and two of them disagreed on the same input: this
|
|
7
|
+
* one **threw** on malformed frontmatter, and a second **silently returned no
|
|
8
|
+
* metadata**, so the same file was a hard error in one code path and a skill
|
|
9
|
+
* named after its own directory with "(no description)" in the other. Refuse
|
|
10
|
+
* versus degrade, on one file shape, is the divergence this module exists to
|
|
11
|
+
* end — see `docs/conventions/refuse-do-not-degrade.md`.
|
|
12
|
+
*
|
|
13
|
+
* **Deliberately not a YAML parser.** It is a flat key/value splitter with one
|
|
14
|
+
* level of nesting, and it refuses the constructs in {@link UNSUPPORTED_YAML}
|
|
15
|
+
* rather than mangling them. That refusal is the design: a reader that
|
|
16
|
+
* half-understands YAML produces a value that passes validation and means
|
|
17
|
+
* nothing.
|
|
18
|
+
*
|
|
19
|
+
* That refusal is now total for lists. A *block* sequence
|
|
20
|
+
*
|
|
21
|
+
* ```yaml
|
|
22
|
+
* allowed-tools:
|
|
23
|
+
* - Read
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* used to be silently dropped — its lines carry no `:` and were skipped, so the
|
|
27
|
+
* key came back **absent** — while the flow form `[Read, Grep]` threw. One
|
|
28
|
+
* spelling of a list was a hard error and the other was silence, and the silent
|
|
29
|
+
* one is the shape an author actually writes, because the block form is the
|
|
30
|
+
* natural YAML for a list.
|
|
31
|
+
*
|
|
32
|
+
* It matters most for a key like `allowed-tools`: a skill that asked for `Bash`
|
|
33
|
+
* and silently did not get it is indistinguishable from one that never asked,
|
|
34
|
+
* which is a capability quietly not granted rather than a formatting nicety.
|
|
35
|
+
* Both readers this replaced behaved that way, so it was inherited rather than
|
|
36
|
+
* introduced; it is now refused, naming the key.
|
|
37
|
+
*
|
|
38
|
+
* **Vocabulary belongs to the caller.** This returns the parsed map; it does
|
|
39
|
+
* not know what a skill needs or what a command needs, and it validates no
|
|
40
|
+
* field names. Widening one caller's metadata type to cover another's is how a
|
|
41
|
+
* skill-shaped API comes to mean something it does not.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* A closing fence is a line of its own, not `---` wherever it appears.
|
|
45
|
+
*
|
|
46
|
+
* An unanchored search found `---` inside a quoted value, inside a URL, inside
|
|
47
|
+
* prose — and cut the frontmatter there, which both truncated the metadata AND
|
|
48
|
+
* spilled the rest of it into the body, where it reaches the system prompt
|
|
49
|
+
* verbatim.
|
|
50
|
+
*
|
|
51
|
+
* `\r?` is explicit rather than incidental. `$` under `/m` already matches
|
|
52
|
+
* before a `\r` because JavaScript counts `\r` as a line terminator, so this
|
|
53
|
+
* pattern worked on CRLF by accident before it worked on purpose. Naming the
|
|
54
|
+
* carriage return keeps the next edit from removing a property nobody knew was
|
|
55
|
+
* being relied on — a file authored on Windows is the ordinary case, not the
|
|
56
|
+
* exotic one.
|
|
57
|
+
*/
|
|
58
|
+
const FRONTMATTER_FENCE = /^---[ \t]*\r?$/m;
|
|
59
|
+
const FRONTMATTER_DELIMITER = '---';
|
|
60
|
+
/**
|
|
61
|
+
* Splits on any of the three line endings — CRLF, LF, and a lone CR.
|
|
62
|
+
*
|
|
63
|
+
* The `\r?\n` half is defence in depth and was measured as such: reducing it to
|
|
64
|
+
* `/\n/` fails no test, because {@link normalizeScalar} trims the stray `\r`
|
|
65
|
+
* off every value anyway. The fence is the load-bearing half for CRLF, and that
|
|
66
|
+
* one has a mutation profile.
|
|
67
|
+
*
|
|
68
|
+
* The lone `\r` is not decoration. Without it a CR-only file is one single
|
|
69
|
+
* "line", and the whole frontmatter collapses into the first key: `name` came
|
|
70
|
+
* back as `"a-skill\rdescription: d"` — a *wrong value*, silently, which is the
|
|
71
|
+
* failure this module exists to end.
|
|
72
|
+
*
|
|
73
|
+
* `loadSkill` was accidentally protected, though not in the way first written
|
|
74
|
+
* here: the collapse leaves no `description` key at all, so the required-field
|
|
75
|
+
* check refused the file before any value could be used. A caller that
|
|
76
|
+
* validates nothing — which is every caller this is now exported for — would
|
|
77
|
+
* have taken the mangled name.
|
|
78
|
+
*/
|
|
79
|
+
const LINE_SPLIT = /\r\n|\r|\n/;
|
|
80
|
+
/**
|
|
81
|
+
* YAML this reader does not implement, refused rather than mangled.
|
|
82
|
+
*
|
|
83
|
+
* The documented contract says "YAML frontmatter" with no restriction — so an
|
|
84
|
+
* author has every reason to write a block scalar or a flow sequence, and no
|
|
85
|
+
* reason to expect what happened next. A `description: >-` followed by an
|
|
86
|
+
* indented paragraph produced the literal string `">-"`, which passed
|
|
87
|
+
* validation and registered with no warning; the skill then existed and was
|
|
88
|
+
* never selected, because its description said nothing. A `[Read, Grep]`
|
|
89
|
+
* became that literal text and was interpolated straight into the prompt.
|
|
90
|
+
*
|
|
91
|
+
* Refusing names the line and the file. That is worse for exactly one file —
|
|
92
|
+
* the one already silently broken — and better for everyone looking for it.
|
|
93
|
+
*/
|
|
94
|
+
const UNSUPPORTED_YAML = [
|
|
95
|
+
{ pattern: /^[>|][-+]?\s*$/, what: 'a block scalar (`>` or `|`)' },
|
|
96
|
+
{ pattern: /^\[.*\]$/, what: 'a flow sequence (`[a, b]`)' },
|
|
97
|
+
{ pattern: /^\{.*\}$/, what: 'a flow mapping (`{a: b}`)' },
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Parse a markdown file's `---` frontmatter.
|
|
101
|
+
*
|
|
102
|
+
* @param raw The file's full contents. LF and CRLF both parse.
|
|
103
|
+
* @param source A label for error messages — a path, or a phrase naming the
|
|
104
|
+
* file. Used verbatim, so the caller controls how its own errors read.
|
|
105
|
+
* @throws If the frontmatter is absent, unclosed, or uses YAML this reader
|
|
106
|
+
* does not implement. It never returns a partial or empty result to stand in
|
|
107
|
+
* for a file it could not read.
|
|
108
|
+
*/
|
|
109
|
+
export function parseFrontmatter(raw, source) {
|
|
110
|
+
const trimmed = raw.trimStart();
|
|
111
|
+
if (!trimmed.startsWith(FRONTMATTER_DELIMITER)) {
|
|
112
|
+
throw new Error(`${source} has no YAML frontmatter`);
|
|
113
|
+
}
|
|
114
|
+
const closing = FRONTMATTER_FENCE.exec(trimmed.slice(FRONTMATTER_DELIMITER.length));
|
|
115
|
+
if (!closing) {
|
|
116
|
+
throw new Error(`${source} has unclosed frontmatter`);
|
|
117
|
+
}
|
|
118
|
+
const endIdx = FRONTMATTER_DELIMITER.length + closing.index;
|
|
119
|
+
const frontmatterRaw = trimmed.slice(FRONTMATTER_DELIMITER.length, endIdx).trim();
|
|
120
|
+
const body = trimmed.slice(endIdx + closing[0].length).trim();
|
|
121
|
+
// `Map`, not an object literal, because the keys come from an untrusted
|
|
122
|
+
// file. `blocks[key] = …` on a plain object with `key === '__proto__'`
|
|
123
|
+
// reaches `Object.prototype` through the inheritance chain and writes
|
|
124
|
+
// **there** — a frontmatter file could set `Object.prototype.metadata` and
|
|
125
|
+
// poison every object in the process. That is not theoretical: it was
|
|
126
|
+
// caught here by an adversarial pass, and the poisoned prototype then
|
|
127
|
+
// showed up in the metadata of an unrelated skill loaded afterwards.
|
|
128
|
+
// A `Map` has no prototype chain for string keys, and `Object.fromEntries`
|
|
129
|
+
// *defines* own properties rather than assigning through setters, so the
|
|
130
|
+
// round trip is safe at both ends.
|
|
131
|
+
const data = new Map();
|
|
132
|
+
const blocks = new Map();
|
|
133
|
+
let currentKey;
|
|
134
|
+
for (const line of frontmatterRaw.split(LINE_SPLIT)) {
|
|
135
|
+
if (!line.trim() || line.trimStart().startsWith('#'))
|
|
136
|
+
continue;
|
|
137
|
+
if (/^\s/.test(line)) {
|
|
138
|
+
if (!currentKey)
|
|
139
|
+
continue;
|
|
140
|
+
// A block sequence item. Refused, not skipped.
|
|
141
|
+
//
|
|
142
|
+
// These lines carry no `:`, so the `continue` below used to drop them
|
|
143
|
+
// and the key — having no scalar value and no mapping entries — came
|
|
144
|
+
// back ABSENT. The flow form `[Read, Grep]` already threw, so one
|
|
145
|
+
// spelling of a list was a hard error and the other was silence.
|
|
146
|
+
//
|
|
147
|
+
// The block form is the more natural YAML for a list, which is what
|
|
148
|
+
// made this worth closing: `allowed-tools` is a list, so this is the
|
|
149
|
+
// shape an author actually writes, and a skill that asked for `Bash`
|
|
150
|
+
// and silently did not get it is indistinguishable from one that never
|
|
151
|
+
// asked. A capability quietly not granted is the worst thing this
|
|
152
|
+
// reader can produce.
|
|
153
|
+
if (/^\s*-\s/.test(line)) {
|
|
154
|
+
throw new Error(`${source}: "${currentKey}" uses a block sequence (a "- " list), which this reader does not support. Write it as a single-line value instead. Refusing rather than reading "${currentKey}" as absent, which is what silently dropping the list would mean.`);
|
|
155
|
+
}
|
|
156
|
+
const colonIdx = line.indexOf(':');
|
|
157
|
+
if (colonIdx === -1)
|
|
158
|
+
continue;
|
|
159
|
+
const key = line.slice(0, colonIdx).trim();
|
|
160
|
+
const value = normalizeScalar(line.slice(colonIdx + 1));
|
|
161
|
+
if (!key || !value)
|
|
162
|
+
continue;
|
|
163
|
+
let block = blocks.get(currentKey);
|
|
164
|
+
if (!block) {
|
|
165
|
+
block = new Map();
|
|
166
|
+
blocks.set(currentKey, block);
|
|
167
|
+
}
|
|
168
|
+
block.set(key, value);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const colonIdx = line.indexOf(':');
|
|
172
|
+
if (colonIdx === -1)
|
|
173
|
+
continue;
|
|
174
|
+
const key = line.slice(0, colonIdx).trim();
|
|
175
|
+
const value = normalizeScalar(line.slice(colonIdx + 1));
|
|
176
|
+
assertReadableScalar(key, value, source);
|
|
177
|
+
currentKey = key;
|
|
178
|
+
if (value)
|
|
179
|
+
data.set(key, value);
|
|
180
|
+
}
|
|
181
|
+
// A key cannot be a scalar and a mapping at once — no YAML file can say
|
|
182
|
+
// that — so refusing here is what makes the illegal state unrepresentable
|
|
183
|
+
// in the returned type rather than merely undocumented. The alternative,
|
|
184
|
+
// picking a precedence, would silently drop half of what the author wrote.
|
|
185
|
+
for (const key of blocks.keys()) {
|
|
186
|
+
if (!data.has(key))
|
|
187
|
+
continue;
|
|
188
|
+
throw new Error(`${source}: "${key}" has both a value and an indented block. A key is one or the other — remove the value, or un-indent the lines beneath it.`);
|
|
189
|
+
}
|
|
190
|
+
const values = new Map();
|
|
191
|
+
for (const [key, value] of data) {
|
|
192
|
+
values.set(key, { kind: 'scalar', value });
|
|
193
|
+
}
|
|
194
|
+
for (const [key, entries] of blocks) {
|
|
195
|
+
values.set(key, { kind: 'mapping', entries: Object.fromEntries(entries) });
|
|
196
|
+
}
|
|
197
|
+
return { values: Object.fromEntries(values), body };
|
|
198
|
+
}
|
|
199
|
+
function normalizeScalar(value) {
|
|
200
|
+
return value
|
|
201
|
+
.trim()
|
|
202
|
+
.replace(/^["']|["']$/g, '')
|
|
203
|
+
.trim();
|
|
204
|
+
}
|
|
205
|
+
function assertReadableScalar(key, value, source) {
|
|
206
|
+
for (const { pattern, what } of UNSUPPORTED_YAML) {
|
|
207
|
+
if (!pattern.test(value))
|
|
208
|
+
continue;
|
|
209
|
+
throw new Error(`${source}: "${key}" uses ${what}, which this reader does not support. Write it as a single-line value instead. Refusing rather than accepting a "${key}" that would read as ${JSON.stringify(value)}.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=frontmatter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../../src/utils/frontmatter.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/public-runtime.ts
CHANGED
|
@@ -142,6 +142,11 @@ export {
|
|
|
142
142
|
resolveSkillChain,
|
|
143
143
|
SkillRegistry,
|
|
144
144
|
} from './skills/index.js'
|
|
145
|
+
// The one frontmatter reader. `loadSkill` is built on it, and a host reading
|
|
146
|
+
// its own markdown — a command file, a prompt template — uses the same one
|
|
147
|
+
// rather than hand-rolling a second that disagrees about CRLF or about whether
|
|
148
|
+
// a malformed file throws or quietly returns nothing.
|
|
149
|
+
export { parseFrontmatter } from './utils/frontmatter.js'
|
|
145
150
|
|
|
146
151
|
// ─── the agent directory ─────────────────────────────────────────────────
|
|
147
152
|
//
|
package/src/public-types.ts
CHANGED
|
@@ -99,6 +99,7 @@ export type {
|
|
|
99
99
|
export type { AdvisoryCallContext, AdvisoryExecutionResult } from './advisory/index.js'
|
|
100
100
|
|
|
101
101
|
export type { ModelPricing } from './utils/cost.js'
|
|
102
|
+
export type { FrontmatterValue, ParsedFrontmatter } from './utils/frontmatter.js'
|
|
102
103
|
export type { Logger } from './utils/logger.js'
|
|
103
104
|
export type { ShellCompressOptions, ShellCompressResult } from './utils/shell-compress.js'
|
|
104
105
|
|
package/src/skills/loader.ts
CHANGED
|
@@ -6,151 +6,98 @@ import type {
|
|
|
6
6
|
SkillLoadResult,
|
|
7
7
|
SkillMetadata,
|
|
8
8
|
} from '../types/skills/index.js'
|
|
9
|
+
import { type ParsedFrontmatter, parseFrontmatter } from '../utils/frontmatter.js'
|
|
9
10
|
import { getRootLogger } from '../utils/logger.js'
|
|
10
11
|
|
|
11
12
|
const logger = getRootLogger().child({ component: 'SkillLoader' })
|
|
12
13
|
|
|
13
14
|
const SKILL_FILENAME = 'SKILL.md'
|
|
14
|
-
const FRONTMATTER_DELIMITER = '---'
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/**
|
|
17
|
+
* How this file's errors name themselves. Passed to the shared reader so a
|
|
18
|
+
* frontmatter failure still reads as a `SKILL.md` failure — the reader is
|
|
19
|
+
* generic, the message is not.
|
|
20
|
+
*/
|
|
21
|
+
function sourceLabel(dirPath: string): string {
|
|
22
|
+
return `SKILL.md at "${dirPath}"`
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const metadata = parseFlatYaml(frontmatterRaw, dirPath)
|
|
43
|
-
|
|
44
|
-
return { metadata, body }
|
|
25
|
+
/**
|
|
26
|
+
* The skill vocabulary, applied to a generic parse.
|
|
27
|
+
*
|
|
28
|
+
* Splitting the file is {@link parseFrontmatter}'s job and knowing what a
|
|
29
|
+
* skill requires is this function's. A command file goes through the same
|
|
30
|
+
* reader and validates an entirely different set of keys.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Read one key, narrowed to the shape this field is declared to have.
|
|
34
|
+
*
|
|
35
|
+
* `Object.hasOwn`, not a bare `values[key]`: a plain property read walks the
|
|
36
|
+
* prototype chain, so a `Object.prototype.description` poisoned by anything
|
|
37
|
+
* else in the process would be picked up here as though the file had declared
|
|
38
|
+
* it. The parser can no longer create that poison; this is the other end of
|
|
39
|
+
* the same guarantee.
|
|
40
|
+
*/
|
|
41
|
+
function scalarAt(values: ParsedFrontmatter['values'], key: string): string | undefined {
|
|
42
|
+
if (!Object.hasOwn(values, key)) return undefined
|
|
43
|
+
const found = values[key]
|
|
44
|
+
return found?.kind === 'scalar' ? found.value : undefined
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (/^\s/.test(line)) {
|
|
57
|
-
if (section !== 'metadata') continue
|
|
58
|
-
const colonIdx = line.indexOf(':')
|
|
59
|
-
if (colonIdx === -1) continue
|
|
60
|
-
const key = line.slice(0, colonIdx).trim()
|
|
61
|
-
const value = normalizeYamlScalar(line.slice(colonIdx + 1).trim())
|
|
62
|
-
if (key && value) metadata[key] = value
|
|
63
|
-
continue
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const colonIdx = line.indexOf(':')
|
|
67
|
-
if (colonIdx === -1) continue
|
|
68
|
-
const key = line.slice(0, colonIdx).trim()
|
|
69
|
-
const value = normalizeYamlScalar(line.slice(colonIdx + 1).trim())
|
|
47
|
+
function mappingAt(
|
|
48
|
+
values: ParsedFrontmatter['values'],
|
|
49
|
+
key: string,
|
|
50
|
+
): Readonly<Record<string, string>> | undefined {
|
|
51
|
+
if (!Object.hasOwn(values, key)) return undefined
|
|
52
|
+
const found = values[key]
|
|
53
|
+
return found?.kind === 'mapping' ? found.entries : undefined
|
|
54
|
+
}
|
|
70
55
|
|
|
71
|
-
|
|
56
|
+
function toSkillMetadata(parsed: ParsedFrontmatter, dirPath: string): SkillMetadata {
|
|
57
|
+
const source = sourceLabel(dirPath)
|
|
58
|
+
const { values } = parsed
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
60
|
+
const name = scalarAt(values, 'name')
|
|
61
|
+
const description = scalarAt(values, 'description')
|
|
76
62
|
|
|
77
|
-
if (!
|
|
78
|
-
throw new Error(
|
|
63
|
+
if (!name) {
|
|
64
|
+
throw new Error(`${source} missing required field: name`)
|
|
79
65
|
}
|
|
80
|
-
if (!
|
|
81
|
-
throw new Error(
|
|
66
|
+
if (!description) {
|
|
67
|
+
throw new Error(`${source} missing required field: description`)
|
|
82
68
|
}
|
|
83
69
|
|
|
84
|
-
validateSkillName(
|
|
85
|
-
validateDescription(
|
|
70
|
+
validateSkillName(name, dirPath)
|
|
71
|
+
validateDescription(description, dirPath)
|
|
86
72
|
|
|
87
|
-
const skillMetadata: SkillMetadata = {
|
|
88
|
-
name: kv.name,
|
|
89
|
-
description: kv.description,
|
|
90
|
-
}
|
|
73
|
+
const skillMetadata: SkillMetadata = { name, description }
|
|
91
74
|
|
|
92
|
-
|
|
93
|
-
|
|
75
|
+
const license = scalarAt(values, 'license')
|
|
76
|
+
if (license) {
|
|
77
|
+
skillMetadata.license = license
|
|
94
78
|
}
|
|
95
79
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
80
|
+
const compatibility = scalarAt(values, 'compatibility')
|
|
81
|
+
if (compatibility) {
|
|
82
|
+
if (compatibility.length > 500) {
|
|
83
|
+
throw new Error(`${source}: compatibility exceeds 500 characters`)
|
|
99
84
|
}
|
|
100
|
-
skillMetadata.compatibility =
|
|
85
|
+
skillMetadata.compatibility = compatibility
|
|
101
86
|
}
|
|
102
87
|
|
|
103
|
-
|
|
104
|
-
|
|
88
|
+
const allowedTools = scalarAt(values, 'allowed-tools')
|
|
89
|
+
if (allowedTools) {
|
|
90
|
+
skillMetadata.allowedTools = allowedTools
|
|
105
91
|
}
|
|
106
92
|
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
const extra = mappingAt(values, 'metadata')
|
|
94
|
+
if (extra && Object.keys(extra).length > 0) {
|
|
95
|
+
skillMetadata.metadata = { ...extra }
|
|
109
96
|
}
|
|
110
97
|
|
|
111
98
|
return skillMetadata
|
|
112
99
|
}
|
|
113
100
|
|
|
114
|
-
function normalizeYamlScalar(value: string): string {
|
|
115
|
-
return value.replace(/^["']|["']$/g, '').trim()
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** A closing fence is a line of its own, not `---` wherever it appears. */
|
|
119
|
-
const FRONTMATTER_FENCE = /^---[ \t]*$/m
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* YAML this reader does not implement, refused rather than mangled.
|
|
123
|
-
*
|
|
124
|
-
* The frontmatter reader here is a flat key/value splitter, and the
|
|
125
|
-
* documented contract says "YAML frontmatter" with no restriction — so an
|
|
126
|
-
* author has every reason to write a block scalar or a flow sequence, and
|
|
127
|
-
* no reason to expect what happened next. A `description: >-` followed by
|
|
128
|
-
* an indented paragraph produced the literal string `">-"`, which passed
|
|
129
|
-
* validation and registered with no warning; the skill then existed and
|
|
130
|
-
* was never selected, because its description said nothing. A
|
|
131
|
-
* `[Read, Grep]` became that literal text and was interpolated straight
|
|
132
|
-
* into the prompt.
|
|
133
|
-
*
|
|
134
|
-
* Refusing names the line and the file. That is worse for exactly one
|
|
135
|
-
* skill — the one already silently broken — and better for everyone
|
|
136
|
-
* looking for it.
|
|
137
|
-
*/
|
|
138
|
-
const UNSUPPORTED_YAML = [
|
|
139
|
-
{ pattern: /^[>|][-+]?\s*$/, what: 'a block scalar (`>` or `|`)' },
|
|
140
|
-
{ pattern: /^\[.*\]$/, what: 'a flow sequence (`[a, b]`)' },
|
|
141
|
-
{ pattern: /^\{.*\}$/, what: 'a flow mapping (`{a: b}`)' },
|
|
142
|
-
] as const
|
|
143
|
-
|
|
144
|
-
function assertReadableScalar(key: string, rawValue: string, dirPath: string): void {
|
|
145
|
-
const value = rawValue.trim()
|
|
146
|
-
for (const { pattern, what } of UNSUPPORTED_YAML) {
|
|
147
|
-
if (!pattern.test(value)) continue
|
|
148
|
-
throw new Error(
|
|
149
|
-
`SKILL.md at "${dirPath}": "${key}" uses ${what}, which this reader does not support. Write it as a single-line value instead. Refusing rather than registering a skill whose "${key}" would read as ${JSON.stringify(value)}.`,
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
101
|
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
155
102
|
|
|
156
103
|
function validateSkillName(name: string, dirPath: string): void {
|
|
@@ -191,10 +138,11 @@ export async function loadSkill(
|
|
|
191
138
|
): Promise<SkillLoadResult> {
|
|
192
139
|
const skillMdPath = join(dirPath, SKILL_FILENAME)
|
|
193
140
|
const raw = await readFile(skillMdPath, 'utf-8')
|
|
194
|
-
const parsed =
|
|
141
|
+
const parsed = parseFrontmatter(raw, sourceLabel(dirPath))
|
|
142
|
+
const metadata = toSkillMetadata(parsed, dirPath)
|
|
195
143
|
|
|
196
144
|
const skill: Skill = {
|
|
197
|
-
metadata
|
|
145
|
+
metadata,
|
|
198
146
|
dirPath,
|
|
199
147
|
}
|
|
200
148
|
|
|
@@ -202,11 +150,11 @@ export async function loadSkill(
|
|
|
202
150
|
skill.body = parsed.body
|
|
203
151
|
}
|
|
204
152
|
|
|
205
|
-
const metadataTokens = estimateTokens(`${
|
|
153
|
+
const metadataTokens = estimateTokens(`${metadata.name}: ${metadata.description}`)
|
|
206
154
|
const bodyTokens = skill.body ? estimateTokens(skill.body) : 0
|
|
207
155
|
|
|
208
156
|
logger.debug('Loaded skill', {
|
|
209
|
-
name:
|
|
157
|
+
name: metadata.name,
|
|
210
158
|
level,
|
|
211
159
|
tokens: metadataTokens + bodyTokens,
|
|
212
160
|
})
|