Skip to content

Commit 3298d09

Browse files
committed
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.
1 parent 202af61 commit 3298d09

5 files changed

Lines changed: 166 additions & 0 deletions

File tree

packages/cli-v3/src/build/buildWorker.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
22
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
33
import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js";
4+
import { bundleSkills } from "./bundleSkills.js";
45
import {
56
createBuildContext,
67
notifyExtensionOnBuildComplete,
78
notifyExtensionOnBuildStart,
89
resolvePluginsForContext,
910
} from "./extensions.js";
1011
import { createExternalsBuildExtension } from "./externals.js";
12+
import { tmpdir } from "node:os";
13+
import { mkdtemp } from "node:fs/promises";
1114
import { join, relative, sep } from "node:path";
1215
import { generateContainerfile } from "../deploy/buildImage.js";
1316
import { writeFile } from "node:fs/promises";
@@ -97,6 +100,29 @@ export async function buildWorker(options: BuildWorkerOptions) {
97100
envVars: options.envVars,
98101
});
99102

103+
// Built-in skill bundler — discovers `ai.defineSkill` registrations
104+
// via a local indexer run and copies each skill folder into
105+
// `{destination}/.trigger/skills/{id}/` before Docker COPY picks up
106+
// the bundle. First-class, not a build extension.
107+
const skillsTmpDir = await mkdtemp(join(tmpdir(), "trigger-skills-"));
108+
const skillsBuildManifestPath = join(skillsTmpDir, "build.json");
109+
try {
110+
await writeFile(skillsBuildManifestPath, JSON.stringify(buildManifest));
111+
const skillsResult = await bundleSkills({
112+
buildManifest,
113+
buildManifestPath: skillsBuildManifestPath,
114+
workingDir: resolvedConfig.workingDir,
115+
env: {
116+
...process.env,
117+
...(options.envVars ?? {}),
118+
},
119+
logger: buildContext.logger,
120+
});
121+
buildManifest = skillsResult.buildManifest;
122+
} catch (err) {
123+
logger.debug("Skill bundling failed; continuing without skills", err);
124+
}
125+
100126
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
101127

102128
if (options.target !== "dev") {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { createHash } from "node:crypto";
2+
import { readFile } from "node:fs/promises";
3+
import { dirname, join, resolve as resolvePath } from "node:path";
4+
import type { BuildManifest, SkillManifest } from "@trigger.dev/core/v3/schemas";
5+
import { copyDirectoryRecursive } from "@trigger.dev/build/internal";
6+
import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js";
7+
import { execOptionsForRuntime, type BuildLogger } from "@trigger.dev/core/v3/build";
8+
9+
export type BundleSkillsOptions = {
10+
buildManifest: BuildManifest;
11+
buildManifestPath: string;
12+
workingDir: string;
13+
env: Record<string, string | undefined>;
14+
logger: BuildLogger;
15+
};
16+
17+
export type BundleSkillsResult = {
18+
/** The input manifest, annotated with `skills` on return. */
19+
buildManifest: BuildManifest;
20+
/** Discovered skills, in deterministic order. */
21+
skills: SkillManifest[];
22+
};
23+
24+
/**
25+
* Built-in skill bundler — not an extension. Runs the indexer locally
26+
* against the bundled worker output to discover `ai.defineSkill(...)`
27+
* registrations, validates each skill's `SKILL.md`, and copies the
28+
* folder into `{outputPath}/.trigger/skills/{id}/` so the deploy image
29+
* picks it up via the existing Dockerfile `COPY`.
30+
*
31+
* No `trigger.config.ts` changes required — discovery is side-effect
32+
* based, same mechanism as task/prompt registration.
33+
*/
34+
export async function bundleSkills(
35+
options: BundleSkillsOptions
36+
): Promise<BundleSkillsResult> {
37+
const { buildManifest, buildManifestPath, workingDir, env, logger } = options;
38+
39+
let skills: SkillManifest[];
40+
try {
41+
const workerManifest = await indexWorkerManifest({
42+
runtime: buildManifest.runtime,
43+
indexWorkerPath: buildManifest.indexWorkerEntryPoint,
44+
buildManifestPath,
45+
nodeOptions: execOptionsForRuntime(buildManifest.runtime, buildManifest),
46+
env,
47+
cwd: workingDir,
48+
otelHookInclude: buildManifest.otelImportHook?.include,
49+
otelHookExclude: buildManifest.otelImportHook?.exclude,
50+
handleStdout(data) {
51+
logger.debug(`[bundleSkills] ${data}`);
52+
},
53+
handleStderr(data) {
54+
if (!data.includes("Debugger attached")) {
55+
logger.debug(`[bundleSkills:stderr] ${data}`);
56+
}
57+
},
58+
});
59+
skills = workerManifest.skills ?? [];
60+
} catch (err) {
61+
// Skill discovery via the indexer is best-effort — if the user's
62+
// bundle doesn't load cleanly here the downstream full indexer will
63+
// surface the real error. Warn and continue with no skills.
64+
logger.debug(`[bundleSkills] skill discovery failed: ${(err as Error).message}`);
65+
return { buildManifest, skills: [] };
66+
}
67+
68+
if (skills.length === 0) {
69+
return { buildManifest, skills: [] };
70+
}
71+
72+
const destinationRoot = join(buildManifest.outputPath, ".trigger", "skills");
73+
74+
for (const skill of skills) {
75+
const sourcePath = resolvePath(workingDir, skill.sourcePath);
76+
const skillMdPath = join(sourcePath, "SKILL.md");
77+
78+
let skillMd: string;
79+
try {
80+
skillMd = await readFile(skillMdPath, "utf8");
81+
} catch {
82+
throw new Error(
83+
`Skill "${skill.id}": SKILL.md not found at ${skillMdPath}. ` +
84+
`Registered via ai.defineSkill({ id: "${skill.id}", path: "${skill.sourcePath}" }) ` +
85+
`at ${skill.filePath}.`
86+
);
87+
}
88+
89+
if (!/^---\r?\n[\s\S]*?\r?\n---/.test(skillMd)) {
90+
throw new Error(
91+
`Skill "${skill.id}": SKILL.md at ${skillMdPath} is missing a frontmatter block.`
92+
);
93+
}
94+
if (!/\bname:\s*\S/.test(skillMd) || !/\bdescription:\s*\S/.test(skillMd)) {
95+
throw new Error(
96+
`Skill "${skill.id}": SKILL.md at ${skillMdPath} frontmatter must include both \`name\` and \`description\`.`
97+
);
98+
}
99+
100+
const skillDest = join(destinationRoot, skill.id);
101+
logger.debug(`[bundleSkills] Copying ${sourcePath}${skillDest}`);
102+
await copyDirectoryRecursive(sourcePath, skillDest);
103+
}
104+
105+
// Sort by id for deterministic manifest output
106+
skills = [...skills].sort((a, b) => a.id.localeCompare(b.id));
107+
108+
// Content hash is derived from each SKILL.md's content for cache invalidation
109+
// downstream (dashboard persistence in Phase 2). Not used in Phase 1.
110+
void createHash;
111+
void dirname;
112+
113+
return {
114+
buildManifest: { ...buildManifest, skills },
115+
skills,
116+
};
117+
}

packages/cli-v3/src/dev/devSession.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
logBuildFailure,
1010
logBuildWarnings,
1111
} from "../build/bundle.js";
12+
import { bundleSkills } from "../build/bundleSkills.js";
1213
import {
1314
createBuildContext,
1415
notifyExtensionOnBuildComplete,
@@ -118,6 +119,26 @@ export async function startDevSession({
118119
bundle.metafile
119120
);
120121

122+
// Built-in skill bundling — copies registered skill folders into
123+
// `.trigger/skills/{id}/` so `skill.local()` works at dev runtime.
124+
try {
125+
const buildManifestPath = join(
126+
workerDir?.path ?? destination.path,
127+
"build.json"
128+
);
129+
await writeJSONFile(buildManifestPath, buildManifest);
130+
const skillsResult = await bundleSkills({
131+
buildManifest,
132+
buildManifestPath,
133+
workingDir: rawConfig.workingDir,
134+
env: process.env,
135+
logger: buildContext.logger,
136+
});
137+
buildManifest = skillsResult.buildManifest;
138+
} catch (err) {
139+
logger.debug("Skill bundling failed during dev rebuild", err);
140+
}
141+
121142
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
122143

123144
try {

packages/cli-v3/src/entryPoints/dev-index-worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ await sendMessageInCatalog(
169169
manifest: {
170170
tasks,
171171
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
172+
skills: resourceCatalog.listSkillManifests(),
172173
queues: resourceCatalog.listQueueManifests(),
173174
configPath: buildManifest.configPath,
174175
runtime: buildManifest.runtime,

packages/cli-v3/src/entryPoints/managed-index-worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ await sendMessageInCatalog(
171171
manifest: {
172172
tasks,
173173
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
174+
skills: resourceCatalog.listSkillManifests(),
174175
queues: resourceCatalog.listQueueManifests(),
175176
configPath: buildManifest.configPath,
176177
runtime: buildManifest.runtime,

0 commit comments

Comments
 (0)