Skip to content

Commit f09bd1b

Browse files
committed
feat(core): add skill resource catalog + SkillManifest schemas
SkillMetadata + SkillManifest zod schemas alongside the existing task/prompt ones. registerSkillMetadata / listSkillManifests / getSkillManifest on ResourceCatalog (both Standard and Noop), wired through the ResourceCatalogAPI facade. BuildManifest + WorkerManifest gain an optional `skills` array so the built-in CLI bundler can annotate discoveries and the indexer can report them. Part 1/3 of Phase 1 for the new ai.skills primitive.
1 parent 8b53b58 commit f09bd1b

7 files changed

Lines changed: 213 additions & 4 deletions

File tree

packages/core/src/v3/resource-catalog/catalog.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
1+
import {
2+
PromptManifest,
3+
QueueManifest,
4+
SkillManifest,
5+
SkillMetadata,
6+
TaskManifest,
7+
WorkerManifest,
8+
} from "../schemas/index.js";
29
import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js";
310

411
export interface ResourceCatalog {
@@ -18,4 +25,7 @@ export interface ResourceCatalog {
1825
listPromptManifests(): Array<PromptManifest>;
1926
getPrompt(id: string): PromptMetadataWithFunctions | undefined;
2027
getPromptSchema(id: string): TaskSchema | undefined;
28+
registerSkillMetadata(skill: SkillMetadata): void;
29+
listSkillManifests(): Array<SkillManifest>;
30+
getSkillManifest(id: string): SkillManifest | undefined;
2131
}

packages/core/src/v3/resource-catalog/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
const API_NAME = "resource-catalog";
22

3-
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
3+
import {
4+
PromptManifest,
5+
QueueManifest,
6+
SkillManifest,
7+
SkillMetadata,
8+
TaskManifest,
9+
WorkerManifest,
10+
} from "../schemas/index.js";
411
import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js";
512
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
613
import { type ResourceCatalog } from "./catalog.js";
@@ -93,6 +100,18 @@ export class ResourceCatalogAPI {
93100
return this.#getCatalog().getPromptSchema(id);
94101
}
95102

103+
public registerSkillMetadata(skill: SkillMetadata): void {
104+
this.#getCatalog().registerSkillMetadata(skill);
105+
}
106+
107+
public listSkillManifests(): Array<SkillManifest> {
108+
return this.#getCatalog().listSkillManifests();
109+
}
110+
111+
public getSkillManifest(id: string): SkillManifest | undefined {
112+
return this.#getCatalog().getSkillManifest(id);
113+
}
114+
96115
#getCatalog(): ResourceCatalog {
97116
return getGlobal(API_NAME) ?? NOOP_RESOURCE_CATALOG;
98117
}

packages/core/src/v3/resource-catalog/noopResourceCatalog.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js";
1+
import {
2+
PromptManifest,
3+
QueueManifest,
4+
SkillManifest,
5+
SkillMetadata,
6+
TaskManifest,
7+
WorkerManifest,
8+
} from "../schemas/index.js";
29
import { type PromptMetadataWithFunctions, type TaskMetadataWithFunctions, type TaskSchema } from "../types/index.js";
310
import { ResourceCatalog } from "./catalog.js";
411

@@ -70,4 +77,16 @@ export class NoopResourceCatalog implements ResourceCatalog {
7077
getPromptSchema(id: string): TaskSchema | undefined {
7178
return undefined;
7279
}
80+
81+
registerSkillMetadata(skill: SkillMetadata): void {
82+
// noop
83+
}
84+
85+
listSkillManifests(): Array<SkillManifest> {
86+
return [];
87+
}
88+
89+
getSkillManifest(id: string): SkillManifest | undefined {
90+
return undefined;
91+
}
7392
}

packages/core/src/v3/resource-catalog/standardResourceCatalog.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
PromptManifest,
33
PromptMetadata,
4+
SkillManifest,
5+
SkillMetadata,
46
TaskFileMetadata,
57
TaskMetadata,
68
TaskManifest,
@@ -21,6 +23,8 @@ export class StandardResourceCatalog implements ResourceCatalog {
2123
private _promptSchemas: Map<string, TaskSchema> = new Map();
2224
private _currentFileContext?: Omit<TaskFileMetadata, "exportName">;
2325
private _queueMetadata: Map<string, QueueManifest> = new Map();
26+
private _skillMetadata: Map<string, SkillMetadata> = new Map();
27+
private _skillFileMetadata: Map<string, TaskFileMetadata> = new Map();
2428

2529
setCurrentFileContext(filePath: string, entryPoint: string) {
2630
this._currentFileContext = { filePath, entryPoint };
@@ -239,6 +243,58 @@ export class StandardResourceCatalog implements ResourceCatalog {
239243
};
240244
}
241245

246+
registerSkillMetadata(skill: SkillMetadata): void {
247+
if (!this._currentFileContext) {
248+
return;
249+
}
250+
251+
if (!skill.id) {
252+
return;
253+
}
254+
255+
const existing = this._skillMetadata.get(skill.id);
256+
if (existing && existing.sourcePath !== skill.sourcePath) {
257+
console.warn(
258+
`Skill "${skill.id}" is defined twice with different paths. Keeping the first:\n` +
259+
` existing: ${existing.sourcePath}\n` +
260+
` ignored: ${skill.sourcePath}`
261+
);
262+
return;
263+
}
264+
265+
this._skillFileMetadata.set(skill.id, {
266+
...this._currentFileContext,
267+
});
268+
this._skillMetadata.set(skill.id, skill);
269+
}
270+
271+
listSkillManifests(): Array<SkillManifest> {
272+
const result: Array<SkillManifest> = [];
273+
274+
for (const [id, metadata] of this._skillMetadata) {
275+
const fileMetadata = this._skillFileMetadata.get(id);
276+
if (!fileMetadata) continue;
277+
278+
result.push({
279+
...metadata,
280+
...fileMetadata,
281+
});
282+
}
283+
284+
return result;
285+
}
286+
287+
getSkillManifest(id: string): SkillManifest | undefined {
288+
const metadata = this._skillMetadata.get(id);
289+
const fileMetadata = this._skillFileMetadata.get(id);
290+
if (!metadata || !fileMetadata) return undefined;
291+
292+
return {
293+
...metadata,
294+
...fileMetadata,
295+
};
296+
}
297+
242298
disable() {
243299
// noop
244300
}

packages/core/src/v3/schemas/build.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { z } from "zod";
22
import { ConfigManifest } from "./config.js";
3-
import { PromptManifest, QueueManifest, TaskFile, TaskManifest } from "./schemas.js";
3+
import {
4+
PromptManifest,
5+
QueueManifest,
6+
SkillManifest,
7+
TaskFile,
8+
TaskManifest,
9+
} from "./schemas.js";
410

511
export const BuildExternal = z.object({
612
name: z.string(),
@@ -70,6 +76,8 @@ export const BuildManifest = z.object({
7076
.optional(),
7177
/** Maps output file paths to their content hashes for deduplication during dev */
7278
outputHashes: z.record(z.string()).optional(),
79+
/** Skills discovered and bundled into `.trigger/skills/{id}/` under `outputPath`. */
80+
skills: SkillManifest.array().optional(),
7381
});
7482

7583
export type BuildManifest = z.infer<typeof BuildManifest>;
@@ -87,6 +95,7 @@ export const WorkerManifest = z.object({
8795
configPath: z.string(),
8896
tasks: TaskManifest.array(),
8997
prompts: PromptManifest.array().optional(),
98+
skills: SkillManifest.array().optional(),
9099
queues: QueueManifest.array().optional(),
91100
workerEntryPoint: z.string(),
92101
controllerEntryPoint: z.string().optional(),

packages/core/src/v3/schemas/schemas.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,28 @@ export const PromptManifest = z.object({
246246

247247
export type PromptManifest = z.infer<typeof PromptManifest>;
248248

249+
// ── Skills ────────────────────────────────────────────────────────────────
250+
//
251+
// A skill is a developer-authored folder (SKILL.md + scripts/references/assets)
252+
// bundled into the deploy image. SkillMetadata is registered at module load
253+
// by `ai.defineSkill({ id, path })`; the CLI's built-in bundler picks it up
254+
// during deploy and copies the folder into the deploy image.
255+
256+
const skillMetadata = {
257+
id: z.string(),
258+
/** Path to the skill's source folder, relative to the project root. */
259+
sourcePath: z.string(),
260+
};
261+
262+
export const SkillMetadata = z.object(skillMetadata);
263+
export type SkillMetadata = z.infer<typeof SkillMetadata>;
264+
265+
export const SkillManifest = z.object({
266+
...skillMetadata,
267+
...taskFileMetadata,
268+
});
269+
export type SkillManifest = z.infer<typeof SkillManifest>;
270+
249271
export const PostStartCauses = z.enum(["index", "create", "restore"]);
250272
export type PostStartCauses = z.infer<typeof PostStartCauses>;
251273

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { StandardResourceCatalog } from "../src/v3/resource-catalog/standardResourceCatalog.js";
3+
4+
describe("StandardResourceCatalog — skills", () => {
5+
it("registers and lists a skill manifest", () => {
6+
const catalog = new StandardResourceCatalog();
7+
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
8+
9+
catalog.registerSkillMetadata({ id: "pdf-processing", sourcePath: "./skills/pdf-processing" });
10+
11+
const manifests = catalog.listSkillManifests();
12+
expect(manifests).toHaveLength(1);
13+
expect(manifests[0]).toMatchObject({
14+
id: "pdf-processing",
15+
sourcePath: "./skills/pdf-processing",
16+
filePath: "trigger/chat.ts",
17+
entryPoint: "chat",
18+
});
19+
});
20+
21+
it("getSkillManifest returns the registered skill", () => {
22+
const catalog = new StandardResourceCatalog();
23+
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
24+
catalog.registerSkillMetadata({ id: "a", sourcePath: "./skills/a" });
25+
26+
expect(catalog.getSkillManifest("a")?.sourcePath).toBe("./skills/a");
27+
expect(catalog.getSkillManifest("missing")).toBeUndefined();
28+
});
29+
30+
it("skips registration without a file context", () => {
31+
const catalog = new StandardResourceCatalog();
32+
33+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
34+
35+
expect(catalog.listSkillManifests()).toHaveLength(0);
36+
});
37+
38+
it("warns and ignores when the same id is registered with a different path", () => {
39+
const catalog = new StandardResourceCatalog();
40+
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
41+
42+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
43+
44+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
45+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/other-pdf" });
46+
47+
const manifests = catalog.listSkillManifests();
48+
expect(manifests).toHaveLength(1);
49+
expect(manifests[0]?.sourcePath).toBe("./skills/pdf");
50+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("defined twice"));
51+
52+
warn.mockRestore();
53+
});
54+
55+
it("re-registering the same id + path is idempotent", () => {
56+
const catalog = new StandardResourceCatalog();
57+
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
58+
59+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
60+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
61+
62+
expect(catalog.listSkillManifests()).toHaveLength(1);
63+
});
64+
65+
it("registers multiple distinct skills", () => {
66+
const catalog = new StandardResourceCatalog();
67+
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
68+
69+
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
70+
catalog.registerSkillMetadata({ id: "researcher", sourcePath: "./skills/researcher" });
71+
72+
expect(catalog.listSkillManifests().map((s) => s.id).sort()).toEqual(["pdf", "researcher"]);
73+
});
74+
});

0 commit comments

Comments
 (0)