@frockbot/plugin-skills 0.0.0 → 0.1.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,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isSkillDocumentPathV1,
4
+ isSkillSlugV1,
5
+ parseSkillDocumentV1,
6
+ renderSkillDocumentV1,
7
+ skillDocumentPathV1,
8
+ skillSlugFromNameV1,
9
+ SKILL_MAX_FILE_BYTES,
10
+ } from "./skill-md.js";
11
+
12
+ describe("SKILL.md", () => {
13
+ test("parses GrokBot's frontmatter-plus-body shape", () => {
14
+ const outcome = parseSkillDocumentV1(
15
+ "---\nname: Daily standup\ndescription: Use this when assembling the weekday standup.\n---\n# Steps\n1. Ask the team.\n",
16
+ );
17
+ expect(outcome).toEqual({
18
+ status: "ok",
19
+ document: {
20
+ name: "Daily standup",
21
+ description: "Use this when assembling the weekday standup.",
22
+ body: "# Steps\n1. Ask the team.",
23
+ },
24
+ });
25
+ });
26
+
27
+ test("accepts CRLF, quoted values and extra frontmatter keys", () => {
28
+ const outcome = parseSkillDocumentV1(
29
+ "---\r\nname: \"quoted\"\r\nlicense: MIT\r\ndescription: 'Use this when quoting.'\r\n---\r\nBody.\r\n",
30
+ );
31
+ expect(outcome).toMatchObject({
32
+ status: "ok",
33
+ document: { name: "quoted", description: "Use this when quoting." },
34
+ });
35
+ });
36
+
37
+ test("refuses rather than throws on every malformed shape", () => {
38
+ const cases = [
39
+ ["no frontmatter", "# Just a heading\n"],
40
+ ["unclosed frontmatter", "---\nname: a\ndescription: b\n"],
41
+ ["missing description", "---\nname: a\n---\nbody\n"],
42
+ ["missing name", "---\ndescription: b\n---\nbody\n"],
43
+ ["empty body", "---\nname: a\ndescription: b\n---\n\n"],
44
+ [
45
+ "nested yaml",
46
+ "---\nname: a\nmeta:\n nested: 1\ndescription: b\n---\nbody\n",
47
+ ],
48
+ ["duplicate key", "---\nname: a\nname: b\ndescription: c\n---\nbody\n"],
49
+ [
50
+ "oversized",
51
+ `---\nname: a\ndescription: b\n---\n${"x".repeat(SKILL_MAX_FILE_BYTES)}`,
52
+ ],
53
+ ] as const;
54
+ for (const [label, text] of cases) {
55
+ const outcome = parseSkillDocumentV1(text);
56
+ expect([label, outcome.status]).toEqual([label, "malformed"]);
57
+ }
58
+ });
59
+
60
+ test("bounds the name and the description", () => {
61
+ expect(
62
+ parseSkillDocumentV1(
63
+ `---\nname: ${"n".repeat(65)}\ndescription: b\n---\nbody\n`,
64
+ ).status,
65
+ ).toBe("malformed");
66
+ expect(
67
+ parseSkillDocumentV1(
68
+ `---\nname: a\ndescription: ${"d".repeat(1025)}\n---\nbody\n`,
69
+ ).status,
70
+ ).toBe("malformed");
71
+ });
72
+
73
+ test("renders a document the parser reads back exactly", () => {
74
+ const document = {
75
+ name: "daily-standup",
76
+ description: "Use this when assembling the weekday standup.",
77
+ body: "# Steps\n1. Ask.",
78
+ };
79
+ expect(parseSkillDocumentV1(renderSkillDocumentV1(document))).toEqual({
80
+ status: "ok",
81
+ document,
82
+ });
83
+ });
84
+
85
+ test("recognises and builds Skill paths", () => {
86
+ expect(isSkillDocumentPathV1("skills/a/SKILL.md")).toBe(true);
87
+ expect(isSkillDocumentPathV1("workflows/a/SKILL.md")).toBe(true);
88
+ expect(isSkillDocumentPathV1("SKILL.md")).toBe(false);
89
+ expect(isSkillDocumentPathV1("skills/a/notes.md")).toBe(false);
90
+ expect(skillDocumentPathV1("daily-standup")).toBe(
91
+ "skills/daily-standup/SKILL.md",
92
+ );
93
+ expect(() => skillDocumentPathV1("../escape")).toThrow();
94
+ expect(isSkillSlugV1("Daily")).toBe(false);
95
+ expect(skillSlugFromNameV1("Daily Standup!")).toBe("daily-standup");
96
+ expect(skillSlugFromNameV1("///")).toBeUndefined();
97
+ });
98
+ });
@@ -0,0 +1,163 @@
1
+ // The `SKILL.md` format, decoded.
2
+ //
3
+ // Parity target: GrokBot's skill format (`docs/research/grokbot-computer.md`
4
+ // §2.8) — "a folder containing one `SKILL.md`: YAML frontmatter with `name`
5
+ // and `description` ("use this when …"), then a markdown recipe body". pi and
6
+ // Claude use the same shape (`docs/research/pi-coding-agent.md` §14), so a
7
+ // FrockBot instruction root is portable to and from those harnesses.
8
+ //
9
+ // Deliberately not a YAML parser. A Skill is untrusted content that becomes
10
+ // part of a system prompt, so the frontmatter grammar accepted here is the
11
+ // smallest one that reads every skill the parity target writes: `key: value`
12
+ // lines, one per line, optionally quoted, no nesting, no anchors, no aliases,
13
+ // no multi-line scalars. Anything else is a refusal, never a partial parse.
14
+
15
+ /** Longest `SKILL.md` accepted, in bytes. Well under `WORKSPACE_MAX_FILE_BYTES`. */
16
+ export const SKILL_MAX_FILE_BYTES = 65_536;
17
+ /** Longest `name`, matching the Agent Skills standard. */
18
+ export const SKILL_MAX_NAME_LENGTH = 64;
19
+ /** Longest `description`, matching the Agent Skills standard. */
20
+ export const SKILL_MAX_DESCRIPTION_LENGTH = 1_024;
21
+ /** Most frontmatter keys read before the file is refused as malformed. */
22
+ export const SKILL_MAX_FRONTMATTER_KEYS = 32;
23
+ /** The file name that marks a directory as a Skill. */
24
+ export const SKILL_FILE_NAME = "SKILL.md";
25
+ /** The directory, relative to the instruction root, a written Skill lands in. */
26
+ export const SKILL_DIRECTORY = "skills";
27
+
28
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
29
+
30
+ /** One parsed `SKILL.md`. `body` is the markdown recipe after the frontmatter. */
31
+ export interface SkillDocumentV1 {
32
+ name: string;
33
+ description: string;
34
+ body: string;
35
+ }
36
+
37
+ /** A `SKILL.md` that could not be parsed. Declared, never thrown at the caller. */
38
+ export interface SkillParseFailureV1 {
39
+ reason: string;
40
+ }
41
+
42
+ export type SkillParseOutcomeV1 =
43
+ | { status: "ok"; document: SkillDocumentV1 }
44
+ | ({ status: "malformed" } & SkillParseFailureV1);
45
+
46
+ function unquote(value: string): string {
47
+ if (value.length >= 2) {
48
+ const first = value[0];
49
+ const last = value[value.length - 1];
50
+ if ((first === '"' || first === "'") && first === last) {
51
+ return value.slice(1, -1);
52
+ }
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function malformed(reason: string): SkillParseOutcomeV1 {
58
+ return { status: "malformed", reason };
59
+ }
60
+
61
+ /**
62
+ * Parses one `SKILL.md`. Total: every rejection is a `malformed` outcome, so a
63
+ * hostile file in an instruction root cannot abort a Turn's catalog load.
64
+ */
65
+ export function parseSkillDocumentV1(text: string): SkillParseOutcomeV1 {
66
+ if (text.length > SKILL_MAX_FILE_BYTES) {
67
+ return malformed(`SKILL.md exceeds ${SKILL_MAX_FILE_BYTES} bytes`);
68
+ }
69
+ const normalized = text.replace(/\r\n/g, "\n");
70
+ const lines = normalized.split("\n");
71
+ if (lines[0]?.trim() !== "---") {
72
+ return malformed("SKILL.md must open with a --- frontmatter fence");
73
+ }
74
+ let closing = -1;
75
+ for (let index = 1; index < lines.length; index += 1) {
76
+ if (lines[index]?.trim() === "---") {
77
+ closing = index;
78
+ break;
79
+ }
80
+ }
81
+ if (closing < 0) {
82
+ return malformed("SKILL.md frontmatter is not closed by ---");
83
+ }
84
+ const fields = new Map<string, string>();
85
+ for (let index = 1; index < closing; index += 1) {
86
+ const line = lines[index] ?? "";
87
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
88
+ if (fields.size >= SKILL_MAX_FRONTMATTER_KEYS) {
89
+ return malformed("SKILL.md frontmatter has too many keys");
90
+ }
91
+ const separator = line.indexOf(":");
92
+ if (separator <= 0 || line !== line.trimStart()) {
93
+ return malformed(`SKILL.md frontmatter line ${index + 1} is invalid`);
94
+ }
95
+ const key = line.slice(0, separator).trim();
96
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) {
97
+ return malformed(`SKILL.md frontmatter key "${key}" is invalid`);
98
+ }
99
+ if (fields.has(key)) {
100
+ return malformed(`SKILL.md frontmatter key "${key}" is duplicated`);
101
+ }
102
+ fields.set(key, unquote(line.slice(separator + 1).trim()));
103
+ }
104
+ const name = fields.get("name")?.trim() ?? "";
105
+ const description = fields.get("description")?.trim() ?? "";
106
+ if (!name || name.length > SKILL_MAX_NAME_LENGTH) {
107
+ return malformed("SKILL.md frontmatter needs a bounded name");
108
+ }
109
+ if (!description || description.length > SKILL_MAX_DESCRIPTION_LENGTH) {
110
+ return malformed("SKILL.md frontmatter needs a bounded description");
111
+ }
112
+ const body = lines
113
+ .slice(closing + 1)
114
+ .join("\n")
115
+ .trim();
116
+ if (!body) return malformed("SKILL.md has no body");
117
+ return { status: "ok", document: { name, description, body } };
118
+ }
119
+
120
+ /** Renders a `SKILL.md`. The inverse of `parseSkillDocumentV1` for what it writes. */
121
+ export function renderSkillDocumentV1(document: SkillDocumentV1): string {
122
+ return [
123
+ "---",
124
+ `name: ${document.name}`,
125
+ `description: ${document.description}`,
126
+ "---",
127
+ "",
128
+ document.body.trim(),
129
+ "",
130
+ ].join("\n");
131
+ }
132
+
133
+ /** True when a relative path inside a root names a Skill's `SKILL.md`. */
134
+ export function isSkillDocumentPathV1(path: string): boolean {
135
+ const segments = path.split("/");
136
+ return (
137
+ segments.length >= 2 && segments[segments.length - 1] === SKILL_FILE_NAME
138
+ );
139
+ }
140
+
141
+ /** The relative path a Skill with this slug occupies inside the instruction root. */
142
+ export function skillDocumentPathV1(slug: string): string {
143
+ if (!SLUG.test(slug)) {
144
+ throw new Error("skill slug must be lowercase letters, digits, or hyphens");
145
+ }
146
+ return `${SKILL_DIRECTORY}/${slug}/${SKILL_FILE_NAME}`;
147
+ }
148
+
149
+ /** True when a slug is well formed. Total; never throws. */
150
+ export function isSkillSlugV1(slug: unknown): slug is string {
151
+ return typeof slug === "string" && SLUG.test(slug);
152
+ }
153
+
154
+ /** Derives a slug from a Skill name, for a `skill_write` that omits one. */
155
+ export function skillSlugFromNameV1(name: string): string | undefined {
156
+ const slug = name
157
+ .toLowerCase()
158
+ .replace(/[^a-z0-9]+/g, "-")
159
+ .replace(/^-+|-+$/g, "")
160
+ .slice(0, 64)
161
+ .replace(/-+$/g, "");
162
+ return SLUG.test(slug) ? slug : undefined;
163
+ }