From cc24ab16e5a9a25fbb535cf423112da4dff16553 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 18 Apr 2026 20:04:22 +0100 Subject: [PATCH] feat(cli): built-in skill bundler for trigger dev + deploy New first-class bundling step (not a build extension): after esbuild produces the worker bundle, fork the indexer locally to discover skills registered via ai.skills.define(), validate each skill's SKILL.md, and copy the folder into {outputPath}/.trigger/skills/{id}/. Hooks into both buildWorker() (deploy) and devSession's updateBundle (dev) right after createBuildManifestFromBundle and before the extension onBuildComplete hook, so extensions can observe the annotated manifest.skills. The existing Dockerfile COPY picks up the new .trigger/skills/ subdirectory without changes. Also: managed-index-worker and dev-index-worker now emit resourceCatalog.listSkillManifests() in the INDEX_COMPLETE message so downstream stages can see the skill list. Part 3/3 of Phase 1 for the new ai.skills primitive. --- packages/cli-v3/src/build/buildWorker.ts | 26 ++++ packages/cli-v3/src/build/bundleSkills.ts | 117 ++++++++++++++++++ packages/cli-v3/src/dev/devSession.ts | 21 ++++ .../src/entryPoints/dev-index-worker.ts | 1 + .../src/entryPoints/managed-index-worker.ts | 1 + 5 files changed, 166 insertions(+) create mode 100644 packages/cli-v3/src/build/bundleSkills.ts diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index b23b802f5..59b4be295 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -1,6 +1,7 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js"; +import { bundleSkills } from "./bundleSkills.js"; import { createBuildContext, notifyExtensionOnBuildComplete, @@ -8,6 +9,8 @@ import { resolvePluginsForContext, } from "./extensions.js"; import { createExternalsBuildExtension } from "./externals.js"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { generateContainerfile } from "../deploy/buildImage.js"; import { writeFile } from "node:fs/promises"; @@ -97,6 +100,29 @@ export async function buildWorker(options: BuildWorkerOptions) { envVars: options.envVars, }); + // Built-in skill bundler — discovers `ai.defineSkill` registrations + // via a local indexer run and copies each skill folder into + // `{destination}/.trigger/skills/{id}/` before Docker COPY picks up + // the bundle. First-class, not a build extension. + const skillsTmpDir = await mkdtemp(join(tmpdir(), "trigger-skills-")); + const skillsBuildManifestPath = join(skillsTmpDir, "build.json"); + try { + await writeFile(skillsBuildManifestPath, JSON.stringify(buildManifest)); + const skillsResult = await bundleSkills({ + buildManifest, + buildManifestPath: skillsBuildManifestPath, + workingDir: resolvedConfig.workingDir, + env: { + ...process.env, + ...(options.envVars ?? {}), + }, + logger: buildContext.logger, + }); + buildManifest = skillsResult.buildManifest; + } catch (err) { + logger.debug("Skill bundling failed; continuing without skills", err); + } + buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); if (options.target !== "dev") { diff --git a/packages/cli-v3/src/build/bundleSkills.ts b/packages/cli-v3/src/build/bundleSkills.ts new file mode 100644 index 000000000..fc78ef82c --- /dev/null +++ b/packages/cli-v3/src/build/bundleSkills.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import type { BuildManifest, SkillManifest } from "@trigger.dev/core/v3/schemas"; +import { copyDirectoryRecursive } from "@trigger.dev/build/internal"; +import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js"; +import { execOptionsForRuntime, type BuildLogger } from "@trigger.dev/core/v3/build"; + +export type BundleSkillsOptions = { + buildManifest: BuildManifest; + buildManifestPath: string; + workingDir: string; + env: Record; + logger: BuildLogger; +}; + +export type BundleSkillsResult = { + /** The input manifest, annotated with `skills` on return. */ + buildManifest: BuildManifest; + /** Discovered skills, in deterministic order. */ + skills: SkillManifest[]; +}; + +/** + * Built-in skill bundler — not an extension. Runs the indexer locally + * against the bundled worker output to discover `ai.defineSkill(...)` + * registrations, validates each skill's `SKILL.md`, and copies the + * folder into `{outputPath}/.trigger/skills/{id}/` so the deploy image + * picks it up via the existing Dockerfile `COPY`. + * + * No `trigger.config.ts` changes required — discovery is side-effect + * based, same mechanism as task/prompt registration. + */ +export async function bundleSkills( + options: BundleSkillsOptions +): Promise { + const { buildManifest, buildManifestPath, workingDir, env, logger } = options; + + let skills: SkillManifest[]; + try { + const workerManifest = await indexWorkerManifest({ + runtime: buildManifest.runtime, + indexWorkerPath: buildManifest.indexWorkerEntryPoint, + buildManifestPath, + nodeOptions: execOptionsForRuntime(buildManifest.runtime, buildManifest), + env, + cwd: workingDir, + otelHookInclude: buildManifest.otelImportHook?.include, + otelHookExclude: buildManifest.otelImportHook?.exclude, + handleStdout(data) { + logger.debug(`[bundleSkills] ${data}`); + }, + handleStderr(data) { + if (!data.includes("Debugger attached")) { + logger.debug(`[bundleSkills:stderr] ${data}`); + } + }, + }); + skills = workerManifest.skills ?? []; + } catch (err) { + // Skill discovery via the indexer is best-effort — if the user's + // bundle doesn't load cleanly here the downstream full indexer will + // surface the real error. Warn and continue with no skills. + logger.debug(`[bundleSkills] skill discovery failed: ${(err as Error).message}`); + return { buildManifest, skills: [] }; + } + + if (skills.length === 0) { + return { buildManifest, skills: [] }; + } + + const destinationRoot = join(buildManifest.outputPath, ".trigger", "skills"); + + for (const skill of skills) { + const sourcePath = resolvePath(workingDir, skill.sourcePath); + const skillMdPath = join(sourcePath, "SKILL.md"); + + let skillMd: string; + try { + skillMd = await readFile(skillMdPath, "utf8"); + } catch { + throw new Error( + `Skill "${skill.id}": SKILL.md not found at ${skillMdPath}. ` + + `Registered via ai.defineSkill({ id: "${skill.id}", path: "${skill.sourcePath}" }) ` + + `at ${skill.filePath}.` + ); + } + + if (!/^---\r?\n[\s\S]*?\r?\n---/.test(skillMd)) { + throw new Error( + `Skill "${skill.id}": SKILL.md at ${skillMdPath} is missing a frontmatter block.` + ); + } + if (!/\bname:\s*\S/.test(skillMd) || !/\bdescription:\s*\S/.test(skillMd)) { + throw new Error( + `Skill "${skill.id}": SKILL.md at ${skillMdPath} frontmatter must include both \`name\` and \`description\`.` + ); + } + + const skillDest = join(destinationRoot, skill.id); + logger.debug(`[bundleSkills] Copying ${sourcePath} → ${skillDest}`); + await copyDirectoryRecursive(sourcePath, skillDest); + } + + // Sort by id for deterministic manifest output + skills = [...skills].sort((a, b) => a.id.localeCompare(b.id)); + + // Content hash is derived from each SKILL.md's content for cache invalidation + // downstream (dashboard persistence in Phase 2). Not used in Phase 1. + void createHash; + void dirname; + + return { + buildManifest: { ...buildManifest, skills }, + skills, + }; +} diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 482ebf6fc..aa2764328 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -9,6 +9,7 @@ import { logBuildFailure, logBuildWarnings, } from "../build/bundle.js"; +import { bundleSkills } from "../build/bundleSkills.js"; import { createBuildContext, notifyExtensionOnBuildComplete, @@ -118,6 +119,26 @@ export async function startDevSession({ bundle.metafile ); + // Built-in skill bundling — copies registered skill folders into + // `.trigger/skills/{id}/` so `skill.local()` works at dev runtime. + try { + const buildManifestPath = join( + workerDir?.path ?? destination.path, + "build.json" + ); + await writeJSONFile(buildManifestPath, buildManifest); + const skillsResult = await bundleSkills({ + buildManifest, + buildManifestPath, + workingDir: rawConfig.workingDir, + env: process.env, + logger: buildContext.logger, + }); + buildManifest = skillsResult.buildManifest; + } catch (err) { + logger.debug("Skill bundling failed during dev rebuild", err); + } + buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); try { diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index 53b95ad04..3e48c70e4 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -184,6 +184,7 @@ await sendMessageInCatalog( manifest: { tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), + skills: resourceCatalog.listSkillManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 644673537..1bf73b226 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -180,6 +180,7 @@ await sendMessageInCatalog( manifest: { tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), + skills: resourceCatalog.listSkillManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime,