Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how. | 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"] }, | |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 53 | { group: "Outside g1t", tools: ["lookup_outside", "import_outside", "act_outside", "request_ability"] }, |
| Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next. | 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"] }, | |
| Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how. | 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 | } |