feat(sdk): add skills.define + chat.skills runtime wiring

skills.define({ id, path }) registers a skill with the resource
catalog and returns a SkillHandle. SkillHandle.local() reads the
bundled SKILL.md from ./.trigger/skills/{id}/ at runtime, parses
frontmatter, and returns a ResolvedSkill ready for chat.skills.set().

chat.skills.set([...]) stores resolved skills for the current run.
chat.toStreamTextOptions() auto-injects the skills preamble into the
system prompt and merges three tools — loadSkill, readFile, bash —
scoped per-skill with path-traversal guards and output caps (64 KB
stdout/stderr, 1 MB readFile). Bash executes in the worker container
with the turn's abort signal, no sandbox — skills are developer code.

Shared packages/build/src/internal/copyFiles.ts extracted from the
additionalFiles extension so the CLI's built-in skill bundler and the
existing extension share one glob + copy implementation.

Part 2/3 of Phase 1 for the new ai.skills primitive.
This commit is contained in:
Eric Allam
2026-04-18 20:03:54 +01:00
parent ee673f7679
commit cf4b61e1af
9 changed files with 890 additions and 85 deletions
+1
View File
@@ -1 +1,2 @@
export * from "./internal/additionalFiles.js";
export * from "./internal/copyFiles.js";
+13 -83
View File
@@ -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<FoundStaticAssetFiles> {
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<MatchedStaticAssets> {
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<void> {
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);
}
}
}
+99
View File
@@ -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<MatcherResult[]> {
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<void> {
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<number> {
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<void> {
await mkdir(destination, { recursive: true });
await cp(source, destination, { recursive: true, force: true });
}
+249 -2
View File
@@ -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<ResolvedSkill[]>("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<string, Tool> {
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<string, Tool>;
};
/**
@@ -1787,12 +2007,18 @@ export type ToStreamTextOptionsOptions = {
*/
function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<string, unknown> {
const prompt = locals.get(chatPromptKey);
const skills = locals.get(chatSkillsKey);
const result: Record<string, unknown> = {};
// 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<strin
Object.assign(result, telemetry);
}
// Skills: merge auto-injected tools with any user-provided tools.
// User tools override on name conflict (though we namespace ours).
if (skills && skills.length > 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 }`.
+1
View File
@@ -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";
+211
View File
@@ -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<TIdentifier extends string = string> = {
id: TIdentifier;
/** Path to the skill source folder, relative to the project root. */
path: string;
};
export type SkillHandle<TIdentifier extends string = string> = {
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<ResolvedSkill>;
/**
* Resolve the skill against the dashboard (current/override version).
*
* Not available in Phase 1 — throws. Use `local()` until backend-managed
* skills ship.
*/
resolve(): Promise<ResolvedSkill>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnySkillHandle = SkillHandle<string>;
/** Extract the id literal type from a SkillHandle. */
export type SkillIdentifier<T extends AnySkillHandle> = T extends SkillHandle<infer TId>
? 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<string, unknown> = {};
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<ResolvedSkill> {
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<TIdentifier extends string>(
options: SkillOptions<TIdentifier>
): SkillHandle<TIdentifier> {
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.`
);
},
};
}
+9
View File
@@ -0,0 +1,9 @@
export { defineSkill as define } from "./skill.js";
export type {
AnySkillHandle,
ResolvedSkill,
SkillFrontmatter,
SkillHandle,
SkillIdentifier,
SkillOptions,
} from "./skill.js";
+86
View File
@@ -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);
});
});
@@ -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);
});
});