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:
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+13
@@ -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':
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user