import { createHash } from 'crypto' import { existsSync, readFileSync, readdirSync } from 'fs' import { dirname, join } from 'path' import { create } from 'tar' // The skills tldraw publishes for agents to discover, per the Agent Skills Discovery RFC. Served // from this app but reached at tldraw.dev/.well-known/agent-skills/* through the dotdev proxy, the // same arrangement as the llms*.txt bundles. // // `skills/` in this repo is mostly workflows for contributing *to* tldraw, which are no use to // somebody building *with* it. Only skills that stand alone for an outside reader belong here. /** Where these documents are served from, which is not where this app is deployed. */ export const AGENT_SKILLS_BASE_URL = 'https://tldraw.dev/.well-known/agent-skills' export const AGENT_SKILLS_INDEX_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json' export interface PublishedSkill { /** The skill's directory, relative to the repo root. */ sourceDir: string } /** * Keyed by the skill's name, which is also the route segment it is served under. Adding a skill here * means adding `app/.well-known/agent-skills/.tar.gz/route.ts` (what the index points at) and * `app/.well-known/agent-skills//SKILL.md/route.ts` (readable form); * {@link readPublishedSkill} fails the build if the name and the routes ever disagree. */ export const PUBLISHED_SKILLS = { 'tldraw-migrate': { sourceDir: 'skills/tldraw-migrate' }, } satisfies Record export interface SkillDocument { name: string description: string /** `SKILL.md` on its own, for reading. Not what the index publishes — see {@link archive}. */ markdown: string /** * The whole skill directory as a gzipped tarball, which is what an agent installs. * * `SKILL.md` alone is not installable: tldraw-migrate shells out to `detect-versions.mjs` and * `detect-target.mjs` in the first block an agent runs, and reads `fetch-release-notes.mjs` and * `type-errors.md` later. Publishing the markdown by itself hands over a skill that fails on its * first invocation, with nothing in the index hinting at why. */ archive: Buffer /** `sha256:` over the archive bytes, as the index format requires. */ digest: string url: string } /** Reads from disk, so every route that calls it must stay `force-static`. */ export function readPublishedSkill(key: keyof typeof PUBLISHED_SKILLS): SkillDocument { const { sourceDir } = PUBLISHED_SKILLS[key] const skillDir = resolveSkillDir(sourceDir) const markdown = readFileSync(join(skillDir, 'SKILL.md'), 'utf8') const name = readFrontmatterField(markdown, 'name', sourceDir) const description = readFrontmatterField(markdown, 'description', sourceDir) // The index advertises a URL built from this name, and the route serving that URL is a directory // in `app/`. A rename upstream would publish a link to a 404 — silently, since nothing else reads // both halves. if (name !== key) { throw new Error( `${sourceDir}/SKILL.md is named \`${name}\`, but it is published as \`${key}\`. Rename the route directory to match, or drop it from PUBLISHED_SKILLS.` ) } const archive = buildSkillArchive(sourceDir) return { name, description, markdown, archive, digest: `sha256:${createHash('sha256').update(archive).digest('hex')}`, url: `${AGENT_SKILLS_BASE_URL}/${name}.tar.gz`, } } /** * The skill directory as a `.tar.gz`, with its files at the archive root rather than under a wrapper * directory — the layout the discovery RFC requires, and the one that makes `SKILL.md` land where an * unpacking client looks for it. * * `portable: true` strips uids and gids; `noMtime` is the one that matters, because `portable` * deliberately keeps mtime and every CI checkout stamps a fresh one. Without it the digest changed * on every deploy for byte-identical content, invalidating anything that had cached it. * * Dotfiles are skipped: the only one is a `.gitignore` covering the `references/` cache the skill * fetches at runtime, which means nothing outside this repo. */ function buildSkillArchive(sourceDir: string): Buffer { const cwd = resolveSkillDir(sourceDir) const entries = readdirSync(cwd) .filter((f) => !f.startsWith('.')) .sort() // `sync: true` with no `file` returns a pack whose contents can be read straight out as a buffer. const pack = create( { gzip: true, portable: true, noMtime: true, cwd, sync: true }, entries ) as unknown as { read(): Buffer } return pack.read() } /** * Locates a skill by walking up from the working directory until the path resolves. * * Which directory that starts from depends on the caller: `next build` runs in the app, while the * root `vitest run` starts at the repo root. A fixed number of `..` segments is therefore right for * one caller and wrong for the other — and when it was wrong it read from above the checkout * entirely, so the failure was an ENOENT naming a path outside the repo rather than anything that * pointed here. */ export function resolveSkillDir(sourceDir: string): string { for (let dir = process.cwd(); ; dir = dirname(dir)) { const candidate = join(dir, sourceDir) if (existsSync(join(candidate, 'SKILL.md'))) return candidate if (dirname(dir) === dir) { throw new Error(`No ${sourceDir}/SKILL.md in any directory above ${process.cwd()}`) } } } /** * Pulls one scalar out of the skill's own YAML frontmatter rather than restating it here, so the * published index cannot describe a skill differently from the skill itself. * * Deliberately not a YAML parser: the fields taken are single-line scalars, and a build that fails * loudly on an unreadable one is better than a dependency added to read six lines. */ function readFrontmatterField(markdown: string, field: string, sourceDir: string): string { const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!frontmatter) throw new Error(`${sourceDir}/SKILL.md has no frontmatter`) const match = frontmatter[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm')) if (!match) throw new Error(`${sourceDir}/SKILL.md frontmatter has no \`${field}\``) return match[1].trim().replace(/^['"]|['"]$/g, '') }