Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bundle-skills-single-pass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

Fix `chat.agent` skills silently missing in `trigger dev` for projects whose task files read `process.env` at module top level (e.g. a third-party SDK client initialized at import). Skill folders now bundle into `.trigger/skills/` reliably regardless of which env vars are set when the CLI launches.
145 changes: 85 additions & 60 deletions packages/cli-v3/src/build/bundleSkills.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
import { isAbsolute, 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";
Expand All @@ -21,13 +20,84 @@ export type BundleSkillsResult = {
skills: SkillManifest[];
};

export type CopySkillFoldersOptions = {
skills: SkillManifest[];
/** Root where `{destinationRoot}/{id}/` folders will be created. */
destinationRoot: string;
/** Used to resolve relative `filePath` references in skill manifests. */
workingDir: string;
/** Only `debug` is used. `BuildLogger` and the cli `logger` both satisfy this shape. */
logger: { debug: (...args: unknown[]) => void };
};

/**
* Copy each skill's source folder to `{destinationRoot}/{id}/`. Validates
* that `SKILL.md` exists and has the required frontmatter. Pure file IO —
* no indexer subprocess, no env handling.
*
* Used by the dev path (driven by the main worker indexer's skills list)
* and indirectly by the deploy path (via `bundleSkills` which discovers
* skills via its own indexer pass first, then delegates here).
*/
export async function copySkillFolders(
options: CopySkillFoldersOptions
): Promise<SkillManifest[]> {
const { skills, destinationRoot, workingDir, logger } = options;

if (skills.length === 0) {
return [];
}

for (const skill of skills) {
const callerDir = skill.filePath
? resolvePath(workingDir, skill.filePath, "..")
: workingDir;
const sourcePath = isAbsolute(skill.sourcePath)
? skill.sourcePath
: resolvePath(callerDir, 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 skills.define({ 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(`[copySkillFolders] Copying ${sourcePath} → ${skillDest}`);
await copyDirectoryRecursive(sourcePath, skillDest);
}

return [...skills].sort((a, b) => a.id.localeCompare(b.id));
}

/**
* Built-in skill bundler — not an extension. Runs the indexer locally
* against the bundled worker output to discover `ai.defineSkill(...)`
* against the bundled worker output to discover `skills.define(...)`
* 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`.
*
* Used by the deploy path. The dev path uses `copySkillFolders` directly,
* driven by the main worker indexer that already runs in `BackgroundWorker.initialize` —
* no duplicate indexer pass needed there.
*
* No `trigger.config.ts` changes required — discovery is side-effect
* based, same mechanism as task/prompt registration.
*/
Expand Down Expand Up @@ -71,65 +141,20 @@ export async function bundleSkills(
return { buildManifest, skills: [] };
}

// Destination layout differs between dev and deploy:
// - Dev: the worker runs with cwd = workingDir, so skills must live at
// {workingDir}/.trigger/skills/{id}/ for skill.local() to find them.
// - Deploy: the Dockerfile COPY picks up everything under outputPath into
// /app, so we target {outputPath}/.trigger/skills/{id}/ and the
// container's cwd (/app) resolves correctly.
const destinationRoot =
buildManifest.target === "dev"
? join(workingDir, ".trigger", "skills")
: join(buildManifest.outputPath, ".trigger", "skills");
// Deploy target: the Dockerfile COPY picks up everything under outputPath
// into /app, so we target {outputPath}/.trigger/skills/{id}/ and the
// container's cwd (/app) resolves correctly.
const destinationRoot = join(buildManifest.outputPath, ".trigger", "skills");

for (const skill of skills) {
// Resolve the skill's source folder relative to the file that called
// `skills.define(...)`. Absolute paths are honored as-is.
const callerDir = skill.filePath
? dirname(resolvePath(workingDir, skill.filePath))
: workingDir;
const sourcePath = isAbsolute(skill.sourcePath)
? skill.sourcePath
: resolvePath(callerDir, 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;
const sortedSkills = await copySkillFolders({
skills,
destinationRoot,
workingDir,
logger,
});

return {
buildManifest: { ...buildManifest, skills },
skills,
buildManifest: { ...buildManifest, skills: sortedSkills },
skills: sortedSkills,
};
}
26 changes: 6 additions & 20 deletions packages/cli-v3/src/dev/devSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
logBuildFailure,
logBuildWarnings,
} from "../build/bundle.js";
import { bundleSkills } from "../build/bundleSkills.js";
import {
createBuildContext,
notifyExtensionOnBuildComplete,
Expand Down Expand Up @@ -119,25 +118,12 @@ 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.warn("Skill bundling failed during dev rebuild", err);
}
// Skill folder copying happens after the main worker indexer runs in
// `BackgroundWorker.initialize` — that pass already discovers skills
// via the resource catalog and reports them on `workerManifest.skills`,
// so we don't need a duplicate indexer here (which historically ran
// with a bare `process.env` and silently dropped skills on projects
// whose task files read CLI-injected vars at module top level).

buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);

Expand Down
20 changes: 20 additions & 0 deletions packages/cli-v3/src/dev/devSupervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { eventBus } from "../utilities/eventBus.js";
import { logger } from "../utilities/logger.js";
import { resolveSourceFiles } from "../utilities/sourceFiles.js";
import { BackgroundWorker } from "./backgroundWorker.js";
import { copySkillFolders } from "../build/bundleSkills.js";
import { WorkerRuntime } from "./workerRuntime.js";
import { chalkTask, cliLink, prettyError } from "../utilities/cliOutput.js";
import { DevRunController } from "../entryPoints/dev-run-controller.js";
Expand Down Expand Up @@ -331,6 +332,25 @@ class DevSupervisor implements WorkerRuntime {
throw new Error("Could not initialize worker");
}

// Copy registered skill folders into `${workingDir}/.trigger/skills/{id}/`
// so `skill.local()` can read them at runtime. The main indexer already
// discovered skills; we just do the file IO here.
const discoveredSkills = backgroundWorker.manifest.skills ?? [];
if (discoveredSkills.length > 0) {
try {
await copySkillFolders({
skills: discoveredSkills,
destinationRoot: join(this.options.config.workingDir, ".trigger", "skills"),
workingDir: this.options.config.workingDir,
logger,
});
} catch (err) {
prettyError("Skill bundling failed", (err as Error).message);
stop();
return;
}
}

const validationIssue = validateWorkerManifest(backgroundWorker.manifest);

if (validationIssue) {
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions references/agent-skills/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "references-agent-skills",
"private": true,
"type": "module",
"devDependencies": {
"trigger.dev": "workspace:*"
},
"dependencies": {
"@trigger.dev/build": "workspace:*",
"@trigger.dev/sdk": "workspace:*"
},
"scripts": {
"dev": "trigger dev",
"deploy": "trigger deploy"
}
}
18 changes: 18 additions & 0 deletions references/agent-skills/src/trigger/skills/greeter/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: greeter
description: Say hello in different styles. Use when the user asks for a greeting or a friendly message.
---

# Greeter

A tiny skill used to validate that the CLI bundles `SKILL.md` plus a `scripts/` subfolder into the deploy image and that `skill.local()` can read both at runtime.

## When to use

- Anyone asks for "hello" — invoke `scripts/hello.sh [NAME]` and return its stdout.

## Scripts

### `scripts/hello.sh [NAME]`

Prints `Hello, {NAME}!` (default `world`). Used to confirm `scripts/` is copied alongside `SKILL.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:-world}"
echo "Hello, ${NAME}!"
42 changes: 42 additions & 0 deletions references/agent-skills/src/trigger/test-skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { logger, skills, task } from "@trigger.dev/sdk";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { join } from "node:path";
import { access, constants } from "node:fs/promises";

const greeterSkill = skills.define({
id: "greeter",
path: "./skills/greeter",
});

const execAsync = promisify(exec);

export const testSkillTask = task({
id: "test-skill",
run: async (payload: { name?: string } = {}) => {
const resolved = await greeterSkill.local();

logger.info("Resolved skill", {
id: resolved.id,
version: resolved.version,
path: resolved.path,
frontmatterName: resolved.frontmatter.name,
frontmatterDescription: resolved.frontmatter.description,
bodyChars: resolved.body.length,
});

const scriptPath = join(resolved.path, "scripts", "hello.sh");
await access(scriptPath, constants.X_OK);

const { stdout } = await execAsync(`bash ${scriptPath} ${payload.name ?? "world"}`);

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium test

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
Comment thread
ericallam marked this conversation as resolved.
Dismissed
const output = stdout.trim();
logger.info("Script output", { output });

return {
skillId: resolved.id,
skillPath: resolved.path,
frontmatterName: resolved.frontmatter.name,
scriptOutput: output,
};
},
});
9 changes: 9 additions & 0 deletions references/agent-skills/trigger.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
project: "proj_zweffkxiuovfzsdtjvbe",
runtime: "node",
logLevel: "info",
maxDuration: 60,
dirs: ["./src/trigger"],
});
13 changes: 13 additions & 0 deletions references/agent-skills/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"customConditions": ["@triggerdotdev/source"],
"noEmit": true
},
"include": ["./src/**/*.ts", "trigger.config.ts"]
}
Loading