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
146 lines
6.1 KiB
TypeScript
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, '')
|
|
}
|