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