fix(cli): stop chat.agent skills silently disappearing from trigger dev (#3690)

## Summary

`trigger.dev dev` was silently dropping registered `chat.agent` skills
for any project whose task files read `process.env` at module top level
— e.g. a third-party SDK client initialized at import. The agent would
boot fine, but `skill.local()` failed at runtime with `ENOENT` because
the skill folder was never copied into `.trigger/skills/`.

## Design

The CLI ran two indexer passes in dev: the worker's own indexer (with
the full env it eventually executes tasks in), and a separate
skill-discovery indexer with only the CLI process's env. Top-level reads
of vars like `TRIGGER_API_URL` imported cleanly in the worker pass and
threw in the skill pass — the latter caught the error, warned, and
skipped skill copying. Failure was silent enough that `skill.local()`
only surfaced it at task runtime.

The skill registry is already part of the worker manifest. This PR drops
the duplicate pass and copies skill folders from that manifest after the
worker initializes. One indexer instead of two; a bad `SKILL.md` now
surfaces as a startup error instead of silently disappearing skills.

Deploy is unaffected — its skill discovery uses the project's
environment variables (fetched via the API, which fills in
`TRIGGER_API_URL` etc.), so the dev failure mode doesn't reach there.

## Test plan

- [x] New `references/agent-skills` reference project with
`skills.define` + a task that calls `skill.local()` and runs a bundled
script
- [x] On `main`, adding a top-level
`process.env.TRIGGER_API_URL!.includes(...)` read in any task file
reproduces the symptom: warning at dev startup, no `.trigger/skills/`
folder, `skill.local()` fails with ENOENT
- [x] On this branch, same project boots clean and `skill.local()` works
end-to-end
- [x] Deploy still works end-to-end with the new reference project
This commit is contained in:
Eric Allam
2026-05-21 16:30:24 +01:00
committed by GitHub
parent dbf9b4e7e5
commit 5ddb81ac8d
11 changed files with 231 additions and 80 deletions
+5
View File
@@ -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.
+85 -60
View File
@@ -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";
@@ -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.
*/
@@ -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,
};
}
+6 -20
View File
@@ -9,7 +9,6 @@ import {
logBuildFailure,
logBuildWarnings,
} from "../build/bundle.js";
import { bundleSkills } from "../build/bundleSkills.js";
import {
createBuildContext,
notifyExtensionOnBuildComplete,
@@ -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);
+20
View File
@@ -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";
@@ -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) {
+13
View File
@@ -2223,6 +2223,19 @@ importers:
specifier: 3.25.76
version: 3.25.76
references/agent-skills:
dependencies:
'@trigger.dev/build':
specifier: workspace:*
version: link:../../packages/build
'@trigger.dev/sdk':
specifier: workspace:*
version: link:../../packages/trigger-sdk
devDependencies:
trigger.dev:
specifier: workspace:*
version: link:../../packages/cli-v3
references/ai-chat:
dependencies:
'@ai-sdk/anthropic':
+16
View File
@@ -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"
}
}
@@ -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`.
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:-world}"
echo "Hello, ${NAME}!"
@@ -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"}`);
const output = stdout.trim();
logger.info("Script output", { output });
return {
skillId: resolved.id,
skillPath: resolved.path,
frontmatterName: resolved.frontmatter.name,
scriptOutput: output,
};
},
});
@@ -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
View File
@@ -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"]
}