diff --git a/.changeset/bundle-skills-single-pass.md b/.changeset/bundle-skills-single-pass.md new file mode 100644 index 00000000000..30b2c428b22 --- /dev/null +++ b/.changeset/bundle-skills-single-pass.md @@ -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. diff --git a/packages/cli-v3/src/build/bundleSkills.ts b/packages/cli-v3/src/build/bundleSkills.ts index 65ad9834abe..8533d254c72 100644 --- a/packages/cli-v3/src/build/bundleSkills.ts +++ b/packages/cli-v3/src/build/bundleSkills.ts @@ -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 { + 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, }; } diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 2d6645cd50c..ed6290a1b86 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -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); diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 59b2d2a473b..0d972384c40 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17f73d9a252..d0bb1e5b836 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/references/agent-skills/package.json b/references/agent-skills/package.json new file mode 100644 index 00000000000..b8016c09279 --- /dev/null +++ b/references/agent-skills/package.json @@ -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" + } +} diff --git a/references/agent-skills/src/trigger/skills/greeter/SKILL.md b/references/agent-skills/src/trigger/skills/greeter/SKILL.md new file mode 100644 index 00000000000..a824f138003 --- /dev/null +++ b/references/agent-skills/src/trigger/skills/greeter/SKILL.md @@ -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`. diff --git a/references/agent-skills/src/trigger/skills/greeter/scripts/hello.sh b/references/agent-skills/src/trigger/skills/greeter/scripts/hello.sh new file mode 100755 index 00000000000..b94fa92f76a --- /dev/null +++ b/references/agent-skills/src/trigger/skills/greeter/scripts/hello.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +NAME="${1:-world}" +echo "Hello, ${NAME}!" diff --git a/references/agent-skills/src/trigger/test-skill.ts b/references/agent-skills/src/trigger/test-skill.ts new file mode 100644 index 00000000000..6a6de46665d --- /dev/null +++ b/references/agent-skills/src/trigger/test-skill.ts @@ -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, + }; + }, +}); diff --git a/references/agent-skills/trigger.config.ts b/references/agent-skills/trigger.config.ts new file mode 100644 index 00000000000..5a5c9779107 --- /dev/null +++ b/references/agent-skills/trigger.config.ts @@ -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"], +}); diff --git a/references/agent-skills/tsconfig.json b/references/agent-skills/tsconfig.json new file mode 100644 index 00000000000..2ca0ae6e2d7 --- /dev/null +++ b/references/agent-skills/tsconfig.json @@ -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"] +}