diff --git a/packages/build/src/internal.ts b/packages/build/src/internal.ts index 54f785a61..0e1954c8b 100644 --- a/packages/build/src/internal.ts +++ b/packages/build/src/internal.ts @@ -1 +1,2 @@ export * from "./internal/additionalFiles.js"; +export * from "./internal/copyFiles.js"; diff --git a/packages/build/src/internal/additionalFiles.ts b/packages/build/src/internal/additionalFiles.ts index a815b53c9..57a746c36 100644 --- a/packages/build/src/internal/additionalFiles.ts +++ b/packages/build/src/internal/additionalFiles.ts @@ -1,8 +1,10 @@ import { BuildManifest } from "@trigger.dev/core/v3"; import { BuildContext } from "@trigger.dev/core/v3/build"; -import { copyFile, mkdir } from "node:fs/promises"; -import { dirname, join, posix, relative } from "node:path"; -import { glob } from "tinyglobby"; +import { + copyMatcherResults, + findFilesByMatchers, + type MatcherResult, +} from "./copyFiles.js"; export type AdditionalFilesOptions = { files: string[]; @@ -14,12 +16,13 @@ export async function addAdditionalFilesToBuild( context: BuildContext, manifest: BuildManifest ) { - // Copy any static assets to the destination - const staticAssets = await findStaticAssetFiles(options.files ?? [], manifest.outputPath, { - cwd: context.workingDir, - }); + const matcherResults: MatcherResult[] = await findFilesByMatchers( + options.files ?? [], + manifest.outputPath, + { cwd: context.workingDir } + ); - for (const { assets, matcher } of staticAssets) { + for (const { assets, matcher } of matcherResults) { if (assets.length === 0) { context.logger.warn(`[${source}] No files found for matcher`, matcher); } else { @@ -27,80 +30,7 @@ export async function addAdditionalFilesToBuild( } } - await copyStaticAssets(staticAssets, source, context); -} - -type MatchedStaticAssets = { source: string; destination: string }[]; - -type FoundStaticAssetFiles = Array<{ - matcher: string; - assets: MatchedStaticAssets; -}>; - -async function findStaticAssetFiles( - matchers: string[], - destinationPath: string, - options?: { cwd?: string; ignore?: string[] } -): Promise { - const result: FoundStaticAssetFiles = []; - - for (const matcher of matchers) { - const assets = await findStaticAssetsForMatcher(matcher, destinationPath, options); - - result.push({ matcher, assets }); - } - - return result; -} - -async function findStaticAssetsForMatcher( - matcher: string, - destinationPath: string, - options?: { cwd?: string; ignore?: string[] } -): Promise { - const result: MatchedStaticAssets = []; - - const files = await glob({ - patterns: [matcher], - cwd: options?.cwd, - ignore: options?.ignore ?? [], - onlyFiles: true, - absolute: true, + await copyMatcherResults(matcherResults, (pair) => { + context.logger.debug(`[${source}] Copying ${pair.source} to ${pair.destination}`); }); - - let matches = 0; - - for (const file of files) { - matches++; - - const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file) - .split(posix.sep) - .filter((p) => p !== "..") - .join(posix.sep); - - const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir); - - result.push({ - source: file, - destination: relativeDestinationPath, - }); - } - - return result; -} - -async function copyStaticAssets( - staticAssetFiles: FoundStaticAssetFiles, - sourceName: string, - context: BuildContext -): Promise { - for (const { assets } of staticAssetFiles) { - for (const { source, destination } of assets) { - await mkdir(dirname(destination), { recursive: true }); - - context.logger.debug(`[${sourceName}] Copying ${source} to ${destination}`); - - await copyFile(source, destination); - } - } } diff --git a/packages/build/src/internal/copyFiles.ts b/packages/build/src/internal/copyFiles.ts new file mode 100644 index 000000000..6fd3ede95 --- /dev/null +++ b/packages/build/src/internal/copyFiles.ts @@ -0,0 +1,99 @@ +import { cp, copyFile, mkdir } from "node:fs/promises"; +import { dirname, join, posix, relative } from "node:path"; +import { glob } from "tinyglobby"; + +/** + * A single matched asset — source file and its destination inside the + * build output directory. + */ +export type CopyPair = { source: string; destination: string }; + +/** + * Result of a single matcher's glob, grouped with the matcher that + * produced it so callers can warn on empty matches. + */ +export type MatcherResult = { + matcher: string; + assets: CopyPair[]; +}; + +/** + * Glob a set of matchers relative to `cwd` and return pairs describing + * where each matched file should be copied to under `destinationDir`. + * + * Relative paths are preserved under `destinationDir`. Leading `..` + * segments (from `../shared/file.txt` style patterns) are stripped so + * files always land inside the destination. + */ +export async function findFilesByMatchers( + matchers: string[], + destinationDir: string, + options?: { cwd?: string; ignore?: string[] } +): Promise { + const result: MatcherResult[] = []; + const cwd = options?.cwd ?? process.cwd(); + + for (const matcher of matchers) { + const files = await glob({ + patterns: [matcher], + cwd, + ignore: options?.ignore ?? [], + onlyFiles: true, + absolute: true, + }); + + const assets: CopyPair[] = files.map((file) => { + const pathInsideDestinationDir = relative(cwd, file) + .split(posix.sep) + .filter((p) => p !== "..") + .join(posix.sep); + return { + source: file, + destination: join(destinationDir, pathInsideDestinationDir), + }; + }); + + result.push({ matcher, assets }); + } + + return result; +} + +/** + * Copy a single file, creating parent directories as needed. + */ +export async function copyFileEnsuringDir(source: string, destination: string): Promise { + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); +} + +/** + * Copy every pair in the given matcher results. Parent directories are + * created automatically. Returns the total number of files copied. + */ +export async function copyMatcherResults( + matcherResults: MatcherResult[], + onCopy?: (pair: CopyPair) => void +): Promise { + let count = 0; + for (const { assets } of matcherResults) { + for (const pair of assets) { + onCopy?.(pair); + await copyFileEnsuringDir(pair.source, pair.destination); + count++; + } + } + return count; +} + +/** + * Recursively copy a directory to another location. Preserves structure; + * overwrites existing files at the destination. + * + * Used by the built-in skill bundler — we copy entire skill folders as a + * unit, not file-by-file. + */ +export async function copyDirectoryRecursive(source: string, destination: string): Promise { + await mkdir(destination, { recursive: true }); + await cp(source, destination, { recursive: true, force: true }); +} diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 398ef7db7..8f1a9d34c 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -43,6 +43,10 @@ import { auth } from "./auth.js"; import { locals } from "./locals.js"; import { metadata } from "./metadata.js"; import type { ResolvedPrompt } from "./prompt.js"; +import type { ResolvedSkill } from "./skill.js"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as nodePath from "node:path"; import { streams } from "./streams.js"; import { createTask, trigger as triggerTaskInternal } from "./shared.js"; import { resourceCatalog } from "@trigger.dev/core/v3"; @@ -1756,6 +1760,212 @@ function getChatPrompt(): ChatPromptValue { return prompt; } +// --------------------------------------------------------------------------- +// chat.skills — store resolved agent skills and inject them into streamText +// --------------------------------------------------------------------------- + +/** @internal */ +const chatSkillsKey = locals.create("chat.skills"); + +/** Limits applied by the auto-injected `loadSkill` / `readFile` / `bash` tools. */ +const DEFAULT_READ_FILE_BYTES = 1024 * 1024; // 1 MB +const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024; // 64 KB + +/** + * Store resolved skills for the current run. Call from any hook + * (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`. + */ +function setChatSkills(skills: ResolvedSkill[]): void { + locals.set(chatSkillsKey, skills); +} + +/** Read the stored skills. Returns `undefined` if none set. */ +function getChatSkills(): ResolvedSkill[] | undefined { + return locals.get(chatSkillsKey); +} + +/** + * Build the system-prompt preamble advertising available skills. Only the + * frontmatter description surfaces here — full SKILL.md body is loaded + * on-demand via the `loadSkill` tool. + */ +function buildSkillsSystemPrompt(skills: ResolvedSkill[]): string { + if (skills.length === 0) return ""; + const lines = skills.map( + (s) => `- ${s.frontmatter.name}: ${s.frontmatter.description}` + ); + return [ + "Available skills (call `loadSkill` to read the full instructions before using one):", + ...lines, + ].join("\n"); +} + +function truncate(s: string, limit: number): string { + if (s.length <= limit) return s; + return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`; +} + +/** Resolve a skill by its frontmatter `name`. */ +function findSkillByName(skills: ResolvedSkill[], name: string): ResolvedSkill | undefined { + return skills.find((s) => s.frontmatter.name === name); +} + +/** + * Check that `candidate` resolves inside `root` — guards against path + * traversal via `..` or absolute paths. Returns the resolved path or + * throws. + */ +function safeJoinInside(root: string, relative: string): string { + if (nodePath.isAbsolute(relative)) { + throw new Error(`Path must be relative to the skill directory: ${relative}`); + } + const resolved = nodePath.resolve(root, relative); + const normalized = nodePath.resolve(root) + nodePath.sep; + if (resolved !== nodePath.resolve(root) && !resolved.startsWith(normalized)) { + throw new Error(`Path escapes the skill directory: ${relative}`); + } + return resolved; +} + +/** + * Build the three tools we auto-inject into `streamText` when skills are + * set: `loadSkill`, `readFile`, `bash`. Scoped per-skill by name. + * + * Exported so callers can use the same tools outside the auto-wired path + * (e.g. in a `chat.createSession` loop with custom streamText). + */ +export function buildSkillTools(skills: ResolvedSkill[]): Record { + const loadSkill = aiTool({ + description: + "Load the full instructions for a skill by its name. Call this first before using a skill.", + inputSchema: jsonSchema<{ name: string }>({ + type: "object", + properties: { + name: { + type: "string", + description: "The `name` field from the skill's frontmatter.", + }, + }, + required: ["name"], + additionalProperties: false, + } as JSONSchema7), + execute: async ({ name }: { name: string }) => { + const skill = findSkillByName(skills, name); + if (!skill) { + return { + error: `Skill "${name}" not found. Available: ${skills + .map((s) => s.frontmatter.name) + .join(", ")}`, + }; + } + return { + name: skill.frontmatter.name, + description: skill.frontmatter.description, + body: skill.body, + path: skill.path, + }; + }, + }); + + const readFile = aiTool({ + description: + "Read a file from a skill's bundled folder. Paths must be relative to the skill's root.", + inputSchema: jsonSchema<{ skill: string; path: string }>({ + type: "object", + properties: { + skill: { type: "string", description: "The skill's name (from frontmatter)." }, + path: { + type: "string", + description: "Relative path inside the skill folder (e.g. `references/citation-style.md`).", + }, + }, + required: ["skill", "path"], + additionalProperties: false, + } as JSONSchema7), + execute: async ({ skill: skillName, path: relPath }: { skill: string; path: string }) => { + const skill = findSkillByName(skills, skillName); + if (!skill) { + return { error: `Skill "${skillName}" not found.` }; + } + let absolute: string; + try { + absolute = safeJoinInside(skill.path, relPath); + } catch (err) { + return { error: (err as Error).message }; + } + try { + const content = await fs.readFile(absolute, "utf8"); + return { content: truncate(content, DEFAULT_READ_FILE_BYTES) }; + } catch (err) { + return { error: (err as Error).message }; + } + }, + }); + + const bash = aiTool({ + description: + "Run a bash command inside a skill's bundled folder. Use this to invoke the skill's scripts. The working directory is the skill's root.", + inputSchema: jsonSchema<{ skill: string; command: string }>({ + type: "object", + properties: { + skill: { type: "string", description: "The skill's name (from frontmatter)." }, + command: { + type: "string", + description: "Bash command to run. Relative script paths resolve against the skill's root.", + }, + }, + required: ["skill", "command"], + additionalProperties: false, + } as JSONSchema7), + execute: async ( + { skill: skillName, command }: { skill: string; command: string }, + { abortSignal }: { abortSignal?: AbortSignal } = {} + ) => { + const skill = findSkillByName(skills, skillName); + if (!skill) { + return { error: `Skill "${skillName}" not found.` }; + } + return await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + } | { error: string }>((resolvePromise) => { + let child; + try { + child = spawn("bash", ["-c", command], { + cwd: skill.path, + signal: abortSignal, + }); + } catch (err) { + resolvePromise({ error: (err as Error).message }); + return; + } + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + child.once("close", (code: number | null) => { + resolvePromise({ + exitCode: code, + stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES), + stderr: truncate(stderr, DEFAULT_BASH_OUTPUT_BYTES), + }); + }); + child.once("error", (err: Error) => { + resolvePromise({ error: err.message }); + }); + }); + }, + }); + + return { loadSkill, readFile, bash }; +} + /** * Options for {@link toStreamTextOptions}. */ @@ -1772,6 +1982,16 @@ export type ToStreamTextOptionsOptions = { * (e.g. `"openai:gpt-4o"`, `"anthropic:claude-sonnet-4-6"`). */ registry?: { languageModel(modelId: string): unknown }; + /** + * User-defined tools to merge alongside the auto-injected skill tools + * (`loadSkill`, `readFile`, `bash`). User tools win on name conflicts. + * + * If you don't pass `tools` here and skills are set, the returned options + * will include just the skill tools — spread after any `tools` you pass + * directly to `streamText` and they'll be replaced. Easiest: pass all + * your tools here. + */ + tools?: Record; }; /** @@ -1787,12 +2007,18 @@ export type ToStreamTextOptionsOptions = { */ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record { const prompt = locals.get(chatPromptKey); + const skills = locals.get(chatSkillsKey); const result: Record = {}; + // Build the combined system prompt: stored prompt + skills preamble. + const promptText = prompt?.text ?? ""; + const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : ""; + if (promptText || skillsText) { + result.system = [promptText, skillsText].filter(Boolean).join("\n\n"); + } + // Prompt-related options (only if chat.prompt.set() was called) if (prompt) { - result.system = prompt.text; - // Resolve model via registry if both are present if (options?.registry && prompt.model) { result.model = options.registry.languageModel(prompt.model); @@ -1808,6 +2034,15 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record 0) { + const skillTools = buildSkillTools(skills); + result.tools = { ...skillTools, ...(options?.tools ?? {}) }; + } else if (options?.tools) { + result.tools = options.tools; + } + // Auto-inject prepareStep for compaction, pending messages, and background context injection. // This runs regardless of whether a prompt is set — these features are independent. const taskCompaction = locals.get(chatAgentCompactionKey); @@ -6190,6 +6425,18 @@ export const chat = { * - `chat.prompt()` — read the stored prompt (throws if not set) */ prompt: Object.assign(getChatPrompt, { set: setChatPrompt }), + /** + * Store and retrieve resolved agent skills for the current run. + * + * - `chat.skills.set([...])` — store an array of `ResolvedSkill`s + * - `chat.skills()` — read the stored skills (returns undefined if none) + * + * Skills set here are automatically injected into `streamText` by + * `chat.toStreamTextOptions()`: skill descriptions land in the system + * prompt and `loadSkill` / `readFile` / `bash` tools are added to the + * tool set. + */ + skills: Object.assign(getChatSkills, { set: setChatSkills }), /** * Returns an options object ready to spread into `streamText()`. * Reads the stored prompt and returns `{ system, experimental_telemetry, ...config }`. diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index f362af1d1..2e0f883f8 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -65,3 +65,4 @@ export type { ImportEnvironmentVariablesParams } from "./envvars.js"; export { configure, auth } from "./auth.js"; export * as prompts from "./prompts.js"; +export * as skills from "./skills.js"; diff --git a/packages/trigger-sdk/src/v3/skill.ts b/packages/trigger-sdk/src/v3/skill.ts new file mode 100644 index 000000000..a3c145d38 --- /dev/null +++ b/packages/trigger-sdk/src/v3/skill.ts @@ -0,0 +1,211 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resourceCatalog } from "@trigger.dev/core/v3"; + +/** + * Parsed `SKILL.md` frontmatter. Only `name` + `description` are required; + * additional keys are preserved but untyped. + */ +export type SkillFrontmatter = { + name: string; + description: string; + [key: string]: unknown; +}; + +/** + * A resolved skill ready to hand to `chat.skills.set()`. Includes the parsed + * SKILL.md content plus the on-disk path to the bundled skill folder. + */ +export type ResolvedSkill = { + id: string; + /** Skill version — `"local"` in Phase 1 until backend-managed overrides land. */ + version: number | "local"; + /** Labels applied to this version — empty in Phase 1. */ + labels: string[]; + /** Full raw `SKILL.md` content (with frontmatter). */ + skillMd: string; + /** Parsed frontmatter fields. */ + frontmatter: SkillFrontmatter; + /** Body of SKILL.md with the frontmatter block stripped. */ + body: string; + /** Absolute path to the bundled skill folder (scripts, references, assets live here). */ + path: string; +}; + +export type SkillOptions = { + id: TIdentifier; + /** Path to the skill source folder, relative to the project root. */ + path: string; +}; + +export type SkillHandle = { + id: TIdentifier; + /** + * Read the bundled `SKILL.md` from disk and return the resolved skill. + * + * This is the Phase 1 path — backend-managed overrides are not available + * yet. Works locally (during `trigger dev`) and in the deploy image. + */ + local(): Promise; + /** + * Resolve the skill against the dashboard (current/override version). + * + * Not available in Phase 1 — throws. Use `local()` until backend-managed + * skills ship. + */ + resolve(): Promise; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnySkillHandle = SkillHandle; + +/** Extract the id literal type from a SkillHandle. */ +export type SkillIdentifier = T extends SkillHandle + ? TId + : string; + +/** + * Bundled skills are copied to `${cwd}/.trigger/skills/{id}/` by the CLI at + * build time. At runtime the same layout holds for both `trigger dev` (cwd + * = dev output dir) and deploy (cwd = /app). + */ +function bundledSkillPath(id: string): string { + return path.resolve(process.cwd(), ".trigger", "skills", id); +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/; + +/** + * Parse a minimal YAML-subset frontmatter block. We only support top-level + * string keys like `name: foo` and `description: bar`. Enough for SKILL.md + * frontmatter without pulling in a YAML dep. + */ +export function parseFrontmatter(content: string): { + frontmatter: SkillFrontmatter; + body: string; +} { + const match = content.match(FRONTMATTER_RE); + if (!match || !match[1]) { + throw new Error( + "Skill: SKILL.md is missing a frontmatter block. " + + "Expected `---\\nname: ...\\ndescription: ...\\n---` at the top of the file." + ); + } + + const raw = match[1]; + const frontmatter: Record = {}; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf(":"); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + let value = trimmed.slice(idx + 1).trim(); + // Strip surrounding quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key) frontmatter[key] = value; + } + + if (typeof frontmatter.name !== "string" || !frontmatter.name) { + throw new Error("Skill: SKILL.md frontmatter is missing required `name` field."); + } + if (typeof frontmatter.description !== "string" || !frontmatter.description) { + throw new Error("Skill: SKILL.md frontmatter is missing required `description` field."); + } + + const body = content.slice(match[0].length); + + return { frontmatter: frontmatter as SkillFrontmatter, body }; +} + +async function loadLocal(id: string): Promise { + const skillPath = bundledSkillPath(id); + const skillMdPath = path.join(skillPath, "SKILL.md"); + + let skillMd: string; + try { + skillMd = await fs.readFile(skillMdPath, "utf8"); + } catch (err) { + throw new Error( + `Skill "${id}": could not read SKILL.md at ${skillMdPath}. ` + + `Skills must be bundled into .trigger/skills/{id}/ — this usually means ` + + `the CLI build step didn't run, or the skill wasn't registered via ai.defineSkill. ` + + `Underlying error: ${(err as Error).message}` + ); + } + + const { frontmatter, body } = parseFrontmatter(skillMd); + + return { + id, + version: "local", + labels: [], + skillMd, + frontmatter, + body, + path: skillPath, + }; +} + +/** + * Define an agent skill — a developer-authored folder with a `SKILL.md` file + * plus optional `scripts/`, `references/`, and `assets/` subfolders. Registers + * the skill with the resource catalog so the Trigger.dev CLI can bundle it + * into the deploy image automatically (no build extension needed). + * + * Call `.local()` on the returned handle to load the bundled SKILL.md at + * runtime and use it with `chat.skills.set()`. + * + * @example + * ```ts + * // trigger/skills/pdf-processing/SKILL.md + * // trigger/skills/pdf-processing/scripts/extract.py + * import { ai } from "@trigger.dev/sdk"; + * + * export const pdfSkill = ai.defineSkill({ + * id: "pdf-processing", + * path: "./skills/pdf-processing", + * }); + * + * export const agent = chat.agent({ + * id: "docs", + * onChatStart: async () => { + * chat.skills.set([await pdfSkill.local()]); + * }, + * run: async ({ messages, signal }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages, + * abortSignal: signal, + * ...chat.toStreamTextOptions(), + * }); + * }, + * }); + * ``` + */ +export function defineSkill( + options: SkillOptions +): SkillHandle { + resourceCatalog.registerSkillMetadata({ + id: options.id, + sourcePath: options.path, + }); + + return { + id: options.id, + async local() { + return loadLocal(options.id); + }, + async resolve() { + throw new Error( + `Skill "${options.id}": resolve() is not available yet — backend-managed ` + + `skills ship in Phase 2. Use skill.local() instead.` + ); + }, + }; +} diff --git a/packages/trigger-sdk/src/v3/skills.ts b/packages/trigger-sdk/src/v3/skills.ts new file mode 100644 index 000000000..6811cda75 --- /dev/null +++ b/packages/trigger-sdk/src/v3/skills.ts @@ -0,0 +1,9 @@ +export { defineSkill as define } from "./skill.js"; +export type { + AnySkillHandle, + ResolvedSkill, + SkillFrontmatter, + SkillHandle, + SkillIdentifier, + SkillOptions, +} from "./skill.js"; diff --git a/packages/trigger-sdk/test/skill.test.ts b/packages/trigger-sdk/test/skill.test.ts new file mode 100644 index 000000000..61e497933 --- /dev/null +++ b/packages/trigger-sdk/test/skill.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { defineSkill, parseFrontmatter } from "../src/v3/skill.js"; + +describe("parseFrontmatter", () => { + it("parses name + description", () => { + const { frontmatter, body } = parseFrontmatter( + `---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n` + ); + expect(frontmatter.name).toBe("pdf-processing"); + expect(frontmatter.description).toBe("Extract text from PDFs."); + expect(body).toBe("# Body\n\nhello\n"); + }); + + it("strips surrounding quotes", () => { + const { frontmatter } = parseFrontmatter( + `---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n` + ); + expect(frontmatter.name).toBe("quoted-name"); + expect(frontmatter.description).toBe("single quoted"); + }); + + it("throws on missing frontmatter block", () => { + expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/); + }); + + it("throws on missing required name", () => { + expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow( + /missing required `name`/ + ); + }); + + it("throws on missing required description", () => { + expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow( + /missing required `description`/ + ); + }); +}); + +describe("defineSkill.local()", () => { + const originalCwd = process.cwd(); + let workdir: string; + + beforeEach(async () => { + workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-"))); + process.chdir(workdir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(workdir, { recursive: true, force: true }); + }); + + it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => { + const skillDir = path.join(workdir, ".trigger", "skills", "pdf"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n` + ); + + const skill = defineSkill({ id: "pdf", path: "./skills/pdf" }); + const resolved = await skill.local(); + + expect(resolved.id).toBe("pdf"); + expect(resolved.version).toBe("local"); + expect(resolved.labels).toEqual([]); + expect(resolved.frontmatter.name).toBe("pdf"); + expect(resolved.frontmatter.description).toBe("Extract PDF text."); + expect(resolved.body).toContain("# PDF skill"); + expect(resolved.body).toContain("Use scripts/extract.py"); + expect(resolved.path).toBe(skillDir); + }); + + it("throws a useful error when SKILL.md is missing", async () => { + const skill = defineSkill({ id: "missing", path: "./skills/missing" }); + await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/); + }); + + it("resolve() throws with a helpful Phase 1 message", async () => { + const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" }); + await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s); + }); +}); diff --git a/packages/trigger-sdk/test/skillsRuntime.test.ts b/packages/trigger-sdk/test/skillsRuntime.test.ts new file mode 100644 index 000000000..125471ff7 --- /dev/null +++ b/packages/trigger-sdk/test/skillsRuntime.test.ts @@ -0,0 +1,221 @@ +// Import the test harness FIRST so the resource catalog is installed +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { MockLanguageModelV3 } from "ai/test"; +import { simulateReadableStream, streamText } from "ai"; +import { buildSkillTools, chat } from "../src/v3/ai.js"; +import { defineSkill } from "../src/v3/skill.js"; + +function userMessage(text: string, id?: string) { + return { + id: id ?? `u-${Math.random().toString(36).slice(2)}`, + role: "user" as const, + parts: [{ type: "text" as const, text }], + }; +} + +function textStream(text: string) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ]; + return simulateReadableStream({ chunks }); +} + +const originalCwd = process.cwd(); +let workdir: string; + +beforeEach(async () => { + workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-"))); + process.chdir(workdir); + + // Bundled skill layout + const skillDir = path.join(workdir, ".trigger", "skills", "demo"); + await mkdir(path.join(skillDir, "scripts"), { recursive: true }); + await mkdir(path.join(skillDir, "references"), { recursive: true }); + + await writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n` + ); + + const scriptPath = path.join(skillDir, "scripts", "hello.sh"); + await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`); + await chmod(scriptPath, 0o755); + + await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n"); +}); + +afterEach(async () => { + process.chdir(originalCwd); + await rm(workdir, { recursive: true, force: true }); +}); + +describe("chat.skills runtime integration", () => { + it("injects skills preamble into the system prompt", async () => { + let capturedSystem: string | undefined; + + const model = new MockLanguageModelV3({ + doStream: async (opts) => { + const system = opts.prompt.find((m) => m.role === "system"); + capturedSystem = system ? JSON.stringify(system.content) : undefined; + return { stream: textStream("ok") }; + }, + }); + + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + + const agent = chat.agent({ + id: "skills-runtime.system-prompt", + onChatStart: async () => { + chat.skills.set([await skill.local()]); + }, + run: async ({ messages, signal }) => { + return streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "t1" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 20)); + expect(capturedSystem).toContain("Available skills"); + expect(capturedSystem).toContain("demo: Demo skill for tests"); + } finally { + await harness.close(); + } + }); + + it("auto-wires loadSkill / readFile / bash tools", async () => { + let capturedToolNames: string[] = []; + + const model = new MockLanguageModelV3({ + doStream: async (opts) => { + capturedToolNames = (opts.tools ?? []).map((t) => t.name); + return { stream: textStream("ok") }; + }, + }); + + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + + const agent = chat.agent({ + id: "skills-runtime.auto-tools", + onChatStart: async () => { + chat.skills.set([await skill.local()]); + }, + run: async ({ messages, signal }) => { + return streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "t2" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 20)); + expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"])); + } finally { + await harness.close(); + } + }); +}); + +describe("buildSkillTools — direct execute", () => { + it("loadSkill returns body + path for a known skill", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const resolved = await skill.local(); + const tools = buildSkillTools([resolved]); + + const out = await (tools.loadSkill as any).execute({ name: "demo" }); + expect(out.name).toBe("demo"); + expect(out.body).toContain("# Demo"); + expect(out.path).toBe(resolved.path); + }); + + it("loadSkill returns an error for an unknown skill", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.loadSkill as any).execute({ name: "missing" }); + expect(out.error).toContain('Skill "missing" not found'); + }); + + it("readFile reads a bundled reference", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "references/notes.txt", + }); + expect(out.content).toBe("Reference note.\n"); + }); + + it("readFile rejects path traversal", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "../../../../etc/passwd", + }); + expect(out.error).toMatch(/escapes the skill directory/); + }); + + it("readFile rejects absolute paths", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "/etc/passwd", + }); + expect(out.error).toMatch(/must be relative/); + }); + + it("bash runs a bundled script and captures stdout", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.bash as any).execute({ + skill: "demo", + command: "bash scripts/hello.sh world", + }); + expect(out.exitCode).toBe(0); + expect(out.stdout).toContain("hi from world"); + }); + + it("bash reports non-zero exit code", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.bash as any).execute({ + skill: "demo", + command: "exit 7", + }); + expect(out.exitCode).toBe(7); + }); +});