Skip to content
419 linesCodeBlameRaw
1/**
2 * The skill format (docs.g1t.sh/guides/agent-skills/, "The skill format"):
3 * a skill is a folder holding `SKILL.md`, whose YAML front-matter names it
4 * and says when to use it, followed by the instructions, plus optional
5 * files: `scripts/`, `resources/` (or `references/` and `assets/`, as other
6 * tools write them). It is the open `SKILL.md` format, so a skill written
7 * elsewhere imports as it is.
8 *
9 * g1t reads two extra front-matter keys:
10 *
11 * - `tools:` the agent tools the skill uses (a list, or names separated by
12 * commas). Only tools agents have are accepted, and naming one never
13 * gives it to an agent: an agent without it is told that part doesn't
14 * work where it is.
15 * - `requires_computer:` true when the skill needs the agent's own
16 * computer. A skill with files in `scripts/` needs one whatever it says.
17 *
18 * Other keys (`license`, `metadata`, `allowed-tools`, ...) are kept as they
19 * are and change nothing.
20 *
21 * Pure and standalone (no value imports), so services and the site share it
22 * and Node runs its tests on the file as it is.
23 */
24import type { AgentSkill } from "./skills";
25
26/** The most one skill's folder holds, every file together: 1 MB. */
27export const SKILL_FOLDER_MAX_BYTES = 1024 * 1024;
28/** The most skills from the library one agent has, however they are attached. */
29export const SKILLS_PER_AGENT_MAX = 100;
30/** The most files in one skill's folder, `SKILL.md` included. */
31export const SKILL_FILES_MAX = 200;
32/** The longest name: lowercase letters, digits and hyphens. */
33export const SKILL_NAME_MAX = 64;
34/** The longest description ("when to use it"). */
35export const SKILL_DESCRIPTION_MAX = 1024;
36/** Where a repository keeps the skills it mirrors: `.g1t/skills/<name>/SKILL.md`. */
37export const SKILLS_REPO_DIR = ".g1t/skills";
38
39/**
40 * Names the library can't use: g1t's foundational skills', so `use_skill`
41 * always means one thing. The same list as `FOUNDATIONAL_SKILL_IDS`
42 * (a test keeps them equal).
43 */
44export const RESERVED_SKILL_NAMES: readonly string[] = ["documents", "research", "data", "code", "communication", "files"];
45
46/** The tools a skill may name in `tools:`, grouped as the editor shows them. The agents service's tool box has exactly these (a test there checks). */
47export const AGENT_TOOL_GROUPS: { group: string; tools: string[] }[] = [
48 { group: "Code", tools: ["list_repositories", "search_code", "read_file", "list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"] },
49 { group: "Artifacts and files", tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact", "make_file"] },
50 { group: "Chat", tools: ["search_messages", "read_thread", "workspace_roster"] },
51 { group: "Teamwork", tools: ["ask_colleague", "hand_off", "start_session", "post_update", "use_subagent", "bring_in", "use_skill"] },
52 { group: "Memory", tools: ["remember", "forget"] },
53 { group: "Outside g1t", tools: ["lookup_outside", "import_outside", "act_outside", "request_ability"] },
54];
55
56/** Every tool a skill may name. */
57export const AGENT_TOOL_NAMES: readonly string[] = AGENT_TOOL_GROUPS.flatMap((group) => group.tools);
58
59/** One file in a skill's folder: text as it is, anything else as standard base64. */
60export type SkillFile = { path: string; content: string; encoding?: "utf8" | "base64" };
61
62/** A skill folder that passed every check. */
63export type CheckedSkill = {
64 name: string;
65 /** When to use it, from the front-matter. */
66 description: string;
67 tools: string[];
68 /** Said in the front-matter, or it has scripts. */
69 requires_computer: boolean;
70 /** The instructions: SKILL.md after its front-matter. */
71 body: string;
72 /** SKILL.md as written. */
73 skill_md: string;
74 /** Every other file, by path. */
75 files: SkillFile[];
76 /** The files under `scripts/`. */
77 scripts: string[];
78 /** Every file's bytes together. */
79 bytes: number;
80 /** Front-matter keys g1t doesn't read, kept as they are. */
81 extra: Record<string, unknown>;
82};
83
84export type SkillCheck = { ok: true; skill: CheckedSkill } | { ok: false; message: string };
85
86// ── Front-matter ─────────────────────────────────────────────────────────
87
88/** SKILL.md split into its front-matter's text and the body after it. */
89export function splitFrontMatter(text: string): { ok: true; yaml: string; body: string } | { ok: false; message: string } {
90 const normal = text.replace(/^/, "").replace(/\r\n?/g, "\n");
91 if (!normal.startsWith("---\n")) return { ok: false, message: "SKILL.md starts with front-matter: a line of ---, then name: and description:, then another line of ---." };
92 const end = normal.indexOf("\n---", 3);
93 if (end < 0) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
94 const after = normal.slice(end + 4);
95 if (after && !after.startsWith("\n") && !/^-*\s*(\n|$)/.test(after)) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
96 return { ok: true, yaml: normal.slice(4, end + 1), body: after.replace(/^-*[ \t]*\n?/, "") };
97}
98
99type Line = { indent: number; text: string; no: number };
100
101function unquote(raw: string, no: number): unknown {
102 const value = raw.trim();
103 if (value.startsWith('"')) {
104 try {
105 const end = closingQuote(value, '"');
106 if (end < 0) throw new Error();
107 return JSON.parse(value.slice(0, end + 1).replace(/\\'/g, "'"));
108 } catch {
109 throw new Error(`line ${no}: a double-quoted value isn't closed`);
110 }
111 }
112 if (value.startsWith("'")) {
113 const end = closingQuote(value, "'");
114 if (end < 0) throw new Error(`line ${no}: a single-quoted value isn't closed`);
115 return value.slice(1, end).replace(/''/g, "'");
116 }
117 if (value.startsWith("[")) {
118 if (!value.endsWith("]")) throw new Error(`line ${no}: a list in [ ] isn't closed`);
119 const inner = value.slice(1, -1).trim();
120 if (!inner) return [];
121 return splitFlow(inner).map((item) => unquote(item, no));
122 }
123 const plain = value.replace(/\s+#.*$/, "");
124 if (plain === "" || plain === "~" || plain === "null") return null;
125 if (plain === "true" || plain === "True") return true;
126 if (plain === "false" || plain === "False") return false;
127 if (/^-?\d+(\.\d+)?$/.test(plain)) return Number(plain);
128 return plain;
129}
130
131/** Where a quoted value starting at 0 closes, or -1. */
132function closingQuote(value: string, quote: string): number {
133 for (let i = 1; i < value.length; i++) {
134 if (quote === '"' && value[i] === "\\") {
135 i++;
136 continue;
137 }
138 if (value[i] === quote) {
139 if (quote === "'" && value[i + 1] === "'") {
140 i++;
141 continue;
142 }
143 return i;
144 }
145 }
146 return -1;
147}
148
149/** `a, "b, c", d` into its items. */
150function splitFlow(inner: string): string[] {
151 const items: string[] = [];
152 let current = "";
153 let quote: string | null = null;
154 for (let i = 0; i < inner.length; i++) {
155 const c = inner[i]!;
156 if (quote) {
157 current += c;
158 if (c === "\\" && quote === '"') current += inner[++i] ?? "";
159 else if (c === quote) quote = null;
160 } else if (c === '"' || c === "'") {
161 quote = c;
162 current += c;
163 } else if (c === ",") {
164 items.push(current.trim());
165 current = "";
166 } else current += c;
167 }
168 if (current.trim()) items.push(current.trim());
169 return items;
170}
171
172/** A block scalar (`|` or `>`) from the lines under its key. */
173function blockScalar(style: string, lines: Line[]): string {
174 if (!lines.length) return "";
175 const base = Math.min(...lines.filter((l) => l.text.trim()).map((l) => l.indent));
176 const texts = lines.map((l) => (l.text.trim() ? " ".repeat(Math.max(0, l.indent - base)) + l.text.trimEnd() : ""));
177 let out: string;
178 if (style.startsWith("|")) out = texts.join("\n");
179 else {
180 out = "";
181 for (const t of texts) {
182 if (!t) out += "\n";
183 else out += out && !out.endsWith("\n") ? ` ${t}` : t;
184 }
185 }
186 if (style.includes("-")) return out.replace(/\n+$/, "");
187 return `${out.replace(/\n+$/, "")}\n`;
188}
189
190/**
191 * The front-matter as values: the YAML that SKILL.md files use, which is
192 * keys with plain, quoted or block (`|`, `>`) strings, booleans, numbers,
193 * lists (`[a, b]` or `- a` lines) and one level of nested keys
194 * (`metadata:`). Throws with the line that can't be read.
195 */
196export function parseFrontMatter(yaml: string): Record<string, unknown> {
197 const lines: Line[] = yaml.split("\n").map((raw, i) => ({ indent: raw.length - raw.trimStart().length, text: raw.trimStart(), no: i + 2 }));
198 return readMap(lines, 0, lines.length, 0);
199}
200
201function readMap(lines: Line[], from: number, to: number, indent: number): Record<string, unknown> {
202 const out: Record<string, unknown> = {};
203 let i = from;
204 while (i < to) {
205 const line = lines[i]!;
206 if (!line.text || line.text.startsWith("#")) {
207 i++;
208 continue;
209 }
210 if (line.indent !== indent) throw new Error(`line ${line.no}: unexpected indentation`);
211 const match = line.text.match(/^("[^"]+"|'[^']+'|[A-Za-z0-9_.\-]+)\s*:(?:\s+(.*)|\s*)$/);
212 if (!match) throw new Error(`line ${line.no}: expected "key: value"`);
213 const key = match[1]!.replace(/^["']|["']$/g, "");
214 const rest = (match[2] ?? "").trim();
215 // The lines that belong to this key: more indented than it, or blank.
216 let j = i + 1;
217 while (j < to && (!lines[j]!.text || lines[j]!.indent > indent || (lines[j]!.indent === indent && lines[j]!.text.startsWith("- ") && !rest))) j++;
218 // Trailing blank lines belong to the next key.
219 let end = j;
220 while (end > i + 1 && !lines[end - 1]!.text) end--;
221 const child = lines.slice(i + 1, end);
222 if (key in out) throw new Error(`line ${line.no}: ${key} is given twice`);
223 if (/^[|>][+-]?$/.test(rest)) out[key] = blockScalar(rest, child);
224 else if (rest && !rest.startsWith("#")) {
225 const value = unquote(rest, line.no);
226 const more = child.filter((l) => l.text && !l.text.startsWith("#"));
227 // A plain value folded over more lines.
228 if (more.length && typeof value === "string" && !/^["'[]/.test(rest)) out[key] = [value, ...more.map((l) => l.text.trim())].join(" ");
229 else if (more.length) throw new Error(`line ${more[0]!.no}: unexpected indentation`);
230 else out[key] = value;
231 } else {
232 const content = child.filter((l) => l.text && !l.text.startsWith("#"));
233 if (!content.length) out[key] = null;
234 else if (content[0]!.text.startsWith("- ") || content[0]!.text === "-") {
235 out[key] = content.map((l) => {
236 if (!l.text.startsWith("-")) throw new Error(`line ${l.no}: expected "- item"`);
237 return unquote(l.text.slice(1), l.no);
238 });
239 } else {
240 const start = lines.indexOf(content[0]!);
241 out[key] = readMap(lines, start, end, content[0]!.indent);
242 }
243 }
244 i = j;
245 }
246 return out;
247}
248
249/** A value as YAML on one line: plain when that reads the same, else double-quoted. */
250function yamlScalar(value: unknown): string {
251 if (value === null || value === undefined) return "null";
252 if (typeof value === "boolean" || typeof value === "number") return String(value);
253 const text = String(value);
254 if (/^[A-Za-z0-9_][A-Za-z0-9_ ,.;()/'’&+-]*$/.test(text) && !/^(true|false|null|yes|no|~|-?\d+(\.\d+)?)$/i.test(text) && !/\s$/.test(text)) return text;
255 return JSON.stringify(text);
256}
257
258function yamlValue(value: unknown, indent: string): string {
259 if (Array.isArray(value)) return value.length ? `[${value.map(yamlScalar).join(", ")}]` : "[]";
260 if (value && typeof value === "object") {
261 const entries = Object.entries(value as Record<string, unknown>);
262 return `\n${entries.map(([k, v]) => `${indent} ${k}:${keyed(v, `${indent} `)}`).join("\n")}`;
263 }
264 return yamlScalar(value);
265}
266
267/** What follows `key:`: a space and the value, or the nested keys on their own lines. */
268function keyed(value: unknown, indent: string): string {
269 const nested = !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
270 return nested ? yamlValue(value, indent) : ` ${yamlValue(value && typeof value === "object" && !Array.isArray(value) ? null : value, indent)}`;
271}
272
273/** SKILL.md for a skill written in the editor: front-matter (name, description, then g1t's keys and any kept ones), then the instructions. */
274export function renderSkillMd(input: { name: string; description: string; tools?: string[]; requires_computer?: boolean; body: string; extra?: Record<string, unknown> }): string {
275 const lines = ["---", `name: ${input.name}`, `description: ${yamlScalar(input.description.replace(/\s*\n\s*/g, " ").trim())}`];
276 if (input.tools?.length) lines.push(`tools: ${yamlValue(input.tools, "")}`);
277 if (input.requires_computer) lines.push("requires_computer: true");
278 for (const [key, value] of Object.entries(input.extra ?? {})) {
279 if (["name", "description", "tools", "requires_computer"].includes(key)) continue;
280 lines.push(`${key}:${keyed(value, "")}`);
281 }
282 lines.push("---", "", input.body.replace(/\r\n?/g, "\n").trim(), "");
283 return lines.join("\n");
284}
285
286// ── Checks ───────────────────────────────────────────────────────────────
287
288/** Why `name` can't name a skill, or null. */
289export function skillNameProblem(name: string): string | null {
290 if (!name) return "A skill needs a name.";
291 if (name.length > SKILL_NAME_MAX) return `A skill's name is at most ${SKILL_NAME_MAX} characters.`;
292 if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return "A skill's name is lowercase letters, digits and single hyphens, like release-notes.";
293 if (RESERVED_SKILL_NAMES.includes(name)) return `${name} is one of g1t's foundational skills. Choose another name.`;
294 return null;
295}
296
297/** Whether `path` is a plain relative path inside the folder. */
298export function skillPathProblem(path: string): string | null {
299 if (!path || path.length > 255) return `${path || "A file"} isn't a path a skill can hold.`;
300 if (path.startsWith("/") || path.includes("\\") || /[\u0000-\u001f]/.test(path)) return `${path} isn't a path a skill can hold.`;
301 const parts = path.split("/");
302 if (parts.some((part) => !part || part === "." || part === ".." || part === ".git")) return `${path} isn't a path a skill can hold.`;
303 return null;
304}
305
306/** A file's size in bytes. */
307export function skillFileBytes(file: SkillFile): number {
308 if (file.encoding === "base64") {
309 const clean = file.content.replace(/\s+/g, "");
310 return Math.floor((clean.length * 3) / 4) - (clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0);
311 }
312 return new TextEncoder().encode(file.content).length;
313}
314
315/** "12 KB". */
316export function skillSize(bytes: number): string {
317 if (bytes < 1024) return `${bytes} B`;
318 if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
319 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
320}
321
322function listOf(value: unknown): string[] | null {
323 if (value === null || value === undefined) return [];
324 if (Array.isArray(value)) return value.every((v) => typeof v === "string") ? (value as string[]) : null;
325 if (typeof value === "string") return value.split(/[\s,]+/).filter(Boolean);
326 return null;
327}
328
329/**
330 * Checks a skill's folder: one `SKILL.md` at its top with a name and a
331 * description, tools agents have, plain paths, at most `SKILL_FILES_MAX`
332 * files and `SKILL_FOLDER_MAX_BYTES` together. `expectName` is the
333 * folder's name when it must match (a repository's `.g1t/skills/<name>/`).
334 */
335export function checkSkillFolder(input: SkillFile[], options: { expectName?: string | null } = {}): SkillCheck {
336 const bad = (message: string): SkillCheck => ({ ok: false, message });
337 if (!Array.isArray(input) || !input.length) return bad("A skill is a folder with a SKILL.md in it.");
338 if (input.length > SKILL_FILES_MAX) return bad(`A skill holds at most ${SKILL_FILES_MAX} files; this one has ${input.length}.`);
339 const seen = new Set<string>();
340 let skillMd: SkillFile | null = null;
341 const files: SkillFile[] = [];
342 let bytes = 0;
343 for (const raw of input) {
344 if (!raw || typeof raw.path !== "string" || typeof raw.content !== "string") return bad("Each file needs a path and its content.");
345 const path = raw.path.replace(/^\.\//, "");
346 const problem = skillPathProblem(path);
347 if (problem) return bad(problem);
348 if (seen.has(path.toLowerCase())) return bad(`${path} is in the folder twice.`);
349 seen.add(path.toLowerCase());
350 const file: SkillFile = { path, content: raw.content, encoding: raw.encoding === "base64" ? "base64" : "utf8" };
351 if (file.encoding === "base64" && !/^[A-Za-z0-9+/\s]*=*\s*$/.test(file.content)) return bad(`${path} isn't valid base64.`);
352 bytes += skillFileBytes(file);
353 if (path.toLowerCase() === "skill.md") {
354 if (file.encoding === "base64") return bad("SKILL.md is text.");
355 skillMd = { ...file, path: "SKILL.md" };
356 } else files.push(file);
357 }
358 if (bytes > SKILL_FOLDER_MAX_BYTES) return bad(`A skill's folder is at most 1 MB; this one is ${skillSize(bytes)}.`);
359 if (!skillMd) {
360 const nested = files.find((f) => f.path.toLowerCase().endsWith("/skill.md"));
361 return bad(nested ? `SKILL.md belongs at the top of the folder, not in ${nested.path.slice(0, nested.path.lastIndexOf("/"))}.` : "A skill's folder needs a SKILL.md at its top.");
362 }
363 const split = splitFrontMatter(skillMd.content);
364 if (!split.ok) return bad(split.message);
365 let front: Record<string, unknown>;
366 try {
367 front = parseFrontMatter(split.yaml);
368 } catch (error) {
369 return bad(`SKILL.md's front-matter can't be read: ${error instanceof Error ? error.message : String(error)}.`);
370 }
371 const name = typeof front.name === "string" ? front.name.trim() : "";
372 const nameProblem = skillNameProblem(name);
373 if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes.");
374 if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`);
375 const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : "";
376 if (!description) return bad("SKILL.md's front-matter needs a description: when an agent should use the skill.");
377 if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`);
378 const listed = listOf(front.tools);
379 if (!listed) return bad("tools: is a list of tool names, like [read_file, make_file].");
380 const tools = [...new Set(listed.map((t) => t.trim()))].filter(Boolean);
381 const unknown = tools.filter((tool) => !AGENT_TOOL_NAMES.includes(tool));
382 if (unknown.length) return bad(`tools: names ${unknown.join(", ")}, which ${unknown.length === 1 ? "isn't a tool" : "aren't tools"} agents have. Agents' tools are listed in the skills guide.`);
383 const flag = front.requires_computer;
384 if (flag !== undefined && flag !== null && typeof flag !== "boolean") return bad("requires_computer: is true or false.");
385 const body = split.body.trim();
386 if (!body) return bad("SKILL.md needs instructions under its front-matter.");
387 const scripts = files.filter((f) => f.path.startsWith("scripts/")).map((f) => f.path);
388 const extra: Record<string, unknown> = {};
389 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
390 files.sort((a, b) => a.path.localeCompare(b.path));
391 return {
392 ok: true,
393 skill: { name, description, tools, requires_computer: flag === true || scripts.length > 0, body, skill_md: skillMd.content, files, scripts, bytes, extra },
394 };
395}
396
397// ── g1t's foundational skills, as SKILL.md ───────────────────────────────
398
399/**
400 * A foundational skill written out in the same format: its name, when to
401 * use it, the tools its working parts use, then its playbook and, part by
402 * part, what works today and what is coming.
403 */
404export function foundationalSkillMd(skill: AgentSkill): string {
405 const ready = skill.abilities.filter((a) => a.status === "ready");
406 const coming = skill.abilities.filter((a) => a.status === "coming");
407 const tools = [...new Set(ready.flatMap((a) => a.tools))];
408 const body = [
409 `# ${skill.name}`,
410 "",
411 skill.instructions,
412 "",
413 "## What works today",
414 "",
415 ...ready.map((a) => `- **${a.label}**${a.tools.length ? ` (${a.tools.map((t) => `\`${t}\``).join(", ")})` : ""}: ${a.note}`),
416 ...(coming.length ? ["", "## Not yet in g1t", "", ...coming.map((a) => `- **${a.label}**: ${a.note}`)] : []),
417 ].join("\n");
418 return renderSkillMd({ name: skill.id, description: skill.when, tools, body, extra: { metadata: { source: "g1t", version: skill.version } } });
419}