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,