Files
upstream-tldraw/apps/docs/utils/agent-skills.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

146 lines
6.1 KiB
TypeScript

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/<name>.tar.gz/route.ts` (what the index points at) and
* `app/.well-known/agent-skills/<name>/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<string, PublishedSkill>
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:<hex>` 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, '')
}