feat(sdk,cli): namespace agent skills with trigger- and add cost-savings (#3970)

## Summary

Three improvements to the SDK-bundled agent skills (follow-up to the
skills installer):

- **`trigger-` namespace.** The installed skills (`authoring-tasks`,
`getting-started`, …) had generic names that collide with unrelated
skills in a shared agent skills directory. They're now prefixed —
`trigger-authoring-tasks`, `trigger-getting-started`, etc. — matching
the convention the public skills repo already uses.
- **New `trigger-cost-savings` skill.** An MCP-driven cost audit:
right-sizes machines, flags missing `maxDuration`, spots sequential
triggers that could batch, and reviews schedule frequency, using
`list_runs` / `get_run_details` for live analysis.
- **Bundle the full docs.** `@trigger.dev/sdk` now bundles the entire
"Documentation" section of the docs (157 pages) instead of a curated
55-page subset, so an agent has the complete, version-pinned reference
in `node_modules`.

## How the bundling works

`scripts/bundleSdkDocs.ts` now reads `docs/docs.json`, walks the
"Documentation" dropdown, and copies every page under it into the SDK.
The set tracks the docs navigation automatically — add a page to the nav
and it ships, no skill edits needed. The API reference and Guides &
examples dropdowns are intentionally excluded. A skill's `sources:`
frontmatter is now informational only.

The dropped idea of a dedicated `trigger-config` skill is replaced by
references to the bundled build-extension docs (`config/extensions/*`)
from the `trigger-authoring-tasks` config section and the chat-agent
skills.
This commit is contained in:
Eric Allam
2026-06-16 23:27:28 +01:00
committed by GitHub
parent 5f2d437eb5
commit 0c839e8566
16 changed files with 271 additions and 102 deletions
+64 -57
View File
@@ -1,67 +1,52 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
// Snapshots the curated docs that the bundled agent skills cite into the SDK package, so
// AI coding agents can read the version-pinned reference directly from node_modules
// (zero drift). Run as part of `@trigger.dev/sdk`'s build, from the package dir.
// Snapshots the user-facing docs into the SDK package, so AI coding agents can read the
// version-pinned reference directly from node_modules (zero drift). Run as part of
// `@trigger.dev/sdk`'s build, from the package dir.
//
// The "manifest" is the union of every `sources:` entry across the SDK's bundled skills
// (skills/*/SKILL.md). The skill declares what it needs; the build copies exactly that.
// Add a `sources:` line to a skill and its doc ships automatically nothing else to edit.
// The manifest is the entire "Documentation" dropdown in `docs/docs.json` (the
// "Resources for Trigger.dev" tab) — every page under it is bundled. Add a page to that
// nav and it ships automatically; nothing else to edit. The API reference and
// Guides & examples dropdowns are intentionally not bundled. Skills reference into this
// set by path; their `sources:` frontmatter is informational and no longer drives bundling.
//
// Layout: a source `docs/tasks/overview.mdx` (relative to the repo root) is copied to
// Layout: nav page `tasks/overview` is copied from `docs/tasks/overview.mdx` to
// `<sdk>/docs/tasks/overview.mdx`, so a skill at `<sdk>/skills/<name>/SKILL.md` reaches it
// at `../../docs/tasks/overview.mdx` and an agent reaches it at `@trigger.dev/sdk/docs/...`.
const packageDir = process.cwd(); // packages/trigger-sdk when run from the SDK build
const repoRoot = path.resolve(packageDir, "..", "..");
const skillsDir = path.join(packageDir, "skills");
const docsRoot = path.join(repoRoot, "docs");
const outDir = path.join(packageDir, "docs");
/** Pull the `sources:` list out of a SKILL.md YAML frontmatter block (simple line scan, no YAML dep). */
async function readSkillSources(skillMdPath: string): Promise<string[]> {
const txt = await fs.readFile(skillMdPath, "utf8");
const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) return [];
const DROPDOWN = "Documentation";
const lines = fm[1].split(/\r?\n/);
const sources: string[] = [];
let inSources = false;
for (const line of lines) {
if (/^sources:\s*$/.test(line)) {
inSources = true;
continue;
}
if (inSources) {
const item = line.match(/^\s*-\s*(.+?)\s*$/);
if (item) {
sources.push(item[1]);
continue;
}
// A non-list, non-blank line ends the block (next top-level key).
if (line.trim() !== "") break;
/** Recursively collect every page path under a docs.json nav node (groups -> pages, nested). */
function collectPages(node: unknown): string[] {
const out: string[] = [];
if (node && typeof node === "object") {
const n = node as { groups?: unknown[]; pages?: unknown[] };
for (const g of n.groups ?? []) out.push(...collectPages(g));
for (const p of n.pages ?? []) {
if (typeof p === "string") out.push(p);
else out.push(...collectPages(p));
}
}
return sources;
return out;
}
async function collectManifest(): Promise<string[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []);
const all = new Set<string>();
const docsJson = JSON.parse(await fs.readFile(path.join(docsRoot, "docs.json"), "utf8"));
const dropdowns: Array<{ dropdown?: string }> = docsJson?.navigation?.dropdowns ?? [];
const documentation = dropdowns.find((d) => d.dropdown === DROPDOWN);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
const sources = await readSkillSources(skillMd).catch(() => []);
for (const s of sources) {
// Only bundle docs paths; ignore anything that isn't a docs/*.mdx source.
if (s.startsWith("docs/") && s.endsWith(".mdx")) all.add(s);
}
if (!documentation) {
throw new Error(`[bundleSdkDocs] "${DROPDOWN}" dropdown not found in docs/docs.json`);
}
return [...all].sort();
// Page paths are root-relative without extension (e.g. "tasks/overview"); map to docs/*.mdx.
return [...new Set(collectPages(documentation))];
}
async function bundleSdkDocs() {
@@ -69,8 +54,7 @@ async function bundleSdkDocs() {
// image), the repo-level docs/ tree is a separate workspace package that isn't part of that
// build's dependency graph, so it isn't present. The SDK isn't being published there, so
// there's nothing to bundle: skip rather than fail. Publishing always runs from the full
// monorepo where docs/ exists, so the missing-docs guard below still protects releases.
const docsRoot = path.join(repoRoot, "docs");
// monorepo where docs/ exists, so the guards below still protect releases.
try {
await fs.access(docsRoot);
} catch {
@@ -81,41 +65,64 @@ async function bundleSdkDocs() {
const manifest = await collectManifest();
if (manifest.length === 0) {
// Fail the build rather than silently ship the SDK with stale or missing docs.
throw new Error("[bundleSdkDocs] no doc sources found in skills/*/SKILL.md");
// The nav structure changed shape; refuse to ship the SDK with no docs.
throw new Error(`[bundleSdkDocs] no pages found under the "${DROPDOWN}" dropdown`);
}
// Rebuild from scratch so removed sources don't linger in the package.
// Rebuild from scratch so removed pages don't linger in the package.
await fs.rm(outDir, { recursive: true, force: true });
const missing: string[] = [];
let copied = 0;
for (const rel of manifest) {
const src = path.join(repoRoot, rel);
// Defensive: nav paths come from our own docs.json and are URL-style, but a
// fat-fingered `../`, a backslash, or an absolute path shouldn't be able to copy a
// file from outside docs/ into the package. Reject backslashes (Windows separator)
// and both POSIX and Windows absolute forms, then the normalized `..` traversal.
const safeRel = path.posix.normalize(rel);
if (
rel.includes("\\") ||
path.posix.isAbsolute(rel) ||
path.win32.isAbsolute(rel) ||
safeRel.startsWith("..")
) {
throw new Error(`[bundleSdkDocs] invalid nav path "${rel}" under "${DROPDOWN}"`);
}
const src = path.join(docsRoot, `${safeRel}.mdx`);
try {
await fs.access(src);
} catch {
// A nav entry pointing at a nonexistent page is a docs-nav issue, not a bundler one.
// Warn and skip rather than fail the SDK build.
missing.push(rel);
continue;
}
// Strip the leading "docs/" so files land at <sdk>/docs/<subpath>.
const dest = path.join(outDir, rel.slice("docs/".length));
const dest = path.join(outDir, `${safeRel}.mdx`);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(src, dest);
copied++;
}
if (missing.length > 0) {
console.error(
`[bundleSdkDocs] ${missing.length} doc source(s) cited by a skill do not exist:\n` +
missing.map((m) => ` - ${m}`).join("\n") +
`\nFix the skill's sources: list or add the doc.`
console.warn(
`[bundleSdkDocs] ${missing.length} "${DROPDOWN}" nav page(s) have no .mdx and were skipped:\n` +
missing.map((m) => ` - ${m}`).join("\n")
);
process.exit(1);
}
console.log(`[bundleSdkDocs] bundled ${copied} docs into ${path.relative(repoRoot, outDir)}`);
if (copied === 0) {
// Every nav page was missing on disk; refuse to ship the SDK with an empty docs bundle.
throw new Error(`[bundleSdkDocs] 0 docs copied from the "${DROPDOWN}" nav; refusing empty docs bundle`);
}
console.log(
`[bundleSdkDocs] bundled ${copied} docs from the "${DROPDOWN}" nav into ${path.relative(
repoRoot,
outDir
)}`
);
}
bundleSdkDocs().catch((e) => {