@roku-sdk/ai 0.0.0-stage → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +47 -3
  4. package/skills/ads-and-raf/SKILL.md +231 -0
  5. package/skills/ads-and-raf/references/integration-flows.md +332 -0
  6. package/skills/app-packaging/SKILL.md +107 -0
  7. package/skills/app-performance/SKILL.md +133 -0
  8. package/skills/cli-dev-loop/SKILL.md +217 -0
  9. package/skills/cli-dev-loop/references/logs-and-crashes.md +114 -0
  10. package/skills/cpu-and-heap-profiling/SKILL.md +108 -0
  11. package/skills/cpu-and-heap-profiling/references/reading-profiles.md +52 -0
  12. package/skills/data-entry-keyboards/SKILL.md +160 -0
  13. package/skills/device-management/SKILL.md +158 -0
  14. package/skills/dialogs-and-modals/SKILL.md +210 -0
  15. package/skills/effects/SKILL.md +183 -0
  16. package/skills/fetch-and-data-loading/SKILL.md +164 -0
  17. package/skills/fetch-and-data-loading/references/https-and-certificates.md +79 -0
  18. package/skills/flex-grid/SKILL.md +236 -0
  19. package/skills/flex-grid/references/focus-integration.md +355 -0
  20. package/skills/flex-grid/references/plugins.md +201 -0
  21. package/skills/focus-management/SKILL.md +210 -0
  22. package/skills/focus-management/references/focus-recipes.md +290 -0
  23. package/skills/fonts-and-glyphs/SKILL.md +231 -0
  24. package/skills/fonts-and-glyphs/references/glyph-tables.md +128 -0
  25. package/skills/logging-and-errors/SKILL.md +227 -0
  26. package/skills/logging-and-errors/references/uncaught-exceptions.md +137 -0
  27. package/skills/mcp-ai-dev-loop/SKILL.md +189 -0
  28. package/skills/mcp-ai-dev-loop/references/crash-and-console.md +68 -0
  29. package/skills/mcp-ai-dev-loop/references/live-debugging.md +129 -0
  30. package/skills/mcp-setup/SKILL.md +131 -0
  31. package/skills/project-setup/SKILL.md +210 -0
  32. package/skills/project-setup/references/roku-config.md +120 -0
  33. package/skills/screen-navigation/SKILL.md +166 -0
  34. package/skills/sdk-components/SKILL.md +213 -0
  35. package/skills/sdk-components/references/dialogs.md +148 -0
  36. package/skills/sdk-components/references/scenegraph-node-spelling.md +123 -0
  37. package/skills/sdk-installation/SKILL.md +129 -0
  38. package/skills/slideshow/SKILL.md +157 -0
  39. package/skills/slideshow/references/focus-and-handoff.md +187 -0
  40. package/skills/solid-on-roku/SKILL.md +181 -0
  41. package/skills/solid-on-roku/references/hermes-and-exports.md +130 -0
  42. package/skills/testing/SKILL.md +147 -0
  43. package/skills/testing/references/async-and-timing.md +97 -0
  44. package/skills/user-accounts-and-purchases/SKILL.md +164 -0
  45. package/skills-manifest.json +316 -0
  46. package/skills-manifest.schema.json +62 -0
  47. package/src/__tests__/manifest.test.ts +267 -0
  48. package/src/__tests__/snippets.test.ts +221 -0
  49. package/src/__tests__/trigger-routing.test.ts +77 -0
  50. package/src/index.ts +36 -0
  51. package/src/manifest.ts +215 -0
  52. package/src/router.ts +147 -0
  53. package/src/snippets.ts +212 -0
@@ -0,0 +1,221 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { describe, expect, it } from "@jest/globals";
5
+ import { readFileSync } from "fs";
6
+ import { resolve } from "path";
7
+
8
+ import { extractSnippets, SnippetMarkerError, UnclosedFenceError } from "../snippets";
9
+
10
+ const PACKAGE_ROOT = resolve(__dirname, "..", "..");
11
+ const FENCE = "```";
12
+
13
+ /** Documents are built line-by-line so the expected `startLine` can be counted by eye. */
14
+ function md(...lines: string[]): string {
15
+ return lines.join("\n");
16
+ }
17
+
18
+ describe("extractSnippets language filtering", () => {
19
+ it("returns ts and tsx fences", () => {
20
+ const doc = md(`${FENCE}ts`, "const a = 1;", FENCE, "", `${FENCE}tsx`, "const b = 2;", FENCE);
21
+ expect(extractSnippets(doc).map(snippet => snippet.lang)).toEqual(["ts", "tsx"]);
22
+ });
23
+
24
+ it("ignores fences in other languages and untagged fences", () => {
25
+ const doc = md(`${FENCE}bash`, "npm i", FENCE, "", `${FENCE}json`, "{}", FENCE, "", FENCE, "plain", FENCE);
26
+ expect(extractSnippets(doc)).toEqual([]);
27
+ });
28
+
29
+ it("accepts an upper-case language tag", () => {
30
+ expect(extractSnippets(md(`${FENCE}TSX`, "const a = 1;", FENCE))[0]?.lang).toBe("tsx");
31
+ });
32
+
33
+ it("returns an empty list for markdown with no fences", () => {
34
+ expect(extractSnippets("# Title\n\nProse mentioning `tsx` inline.\n")).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe("extractSnippets line offsets", () => {
39
+ it("points startLine at the first body line, counting frontmatter", () => {
40
+ const doc = md("---", "name: focus", "---", "", `${FENCE}tsx`, "const a = 1;", FENCE);
41
+ expect(extractSnippets(doc)[0]?.startLine).toBe(6);
42
+ });
43
+
44
+ it("maps a body offset onto the real file line", () => {
45
+ const doc = md("# Title", "", `${FENCE}tsx`, "const a = 1;", "const b = 2;", FENCE);
46
+ const [snippet] = extractSnippets(doc);
47
+ const offsetOfSecondLine = snippet.text.split("\n").indexOf("const b = 2;");
48
+ expect(snippet.startLine + offsetOfSecondLine).toBe(5);
49
+ });
50
+
51
+ it("excludes the delimiter lines from the body", () => {
52
+ const [snippet] = extractSnippets(md(`${FENCE}tsx`, "const a = 1;", FENCE));
53
+ expect(snippet.text).toBe("const a = 1;");
54
+ });
55
+
56
+ it("keeps offsets intact after an indented fence", () => {
57
+ const doc = md(
58
+ "- A list item:",
59
+ "",
60
+ ` ${FENCE}tsx`,
61
+ " const indented = 1;",
62
+ ` ${FENCE}`,
63
+ "",
64
+ `${FENCE}tsx`,
65
+ "const real = 2;",
66
+ FENCE
67
+ );
68
+ const snippets = extractSnippets(doc);
69
+ expect(snippets).toHaveLength(1);
70
+ expect(snippets[0].startLine).toBe(8);
71
+ });
72
+
73
+ it("keeps offsets intact after a blockquoted fence", () => {
74
+ const doc = md(
75
+ `> ${FENCE}tsx`,
76
+ "> const quoted = 1;",
77
+ `> ${FENCE}`,
78
+ "",
79
+ `${FENCE}tsx`,
80
+ "const real = 2;",
81
+ FENCE
82
+ );
83
+ const snippets = extractSnippets(doc);
84
+ expect(snippets).toHaveLength(1);
85
+ expect(snippets[0].startLine).toBe(6);
86
+ });
87
+
88
+ it("keeps offsets intact for a file that ends with a newline", () => {
89
+ const doc = `${md(`${FENCE}tsx`, "const a = 1;", FENCE)}\n`;
90
+ expect(extractSnippets(doc)[0].startLine).toBe(2);
91
+ });
92
+
93
+ it("does not end a fence on a shorter delimiter inside it", () => {
94
+ const doc = md("````tsx", `const md = \`${FENCE}\`;`, "````", "", `${FENCE}tsx`, "const after = 1;", FENCE);
95
+ const snippets = extractSnippets(doc);
96
+ expect(snippets).toHaveLength(2);
97
+ expect(snippets[1].startLine).toBe(6);
98
+ });
99
+
100
+ it("scans tilde fences", () => {
101
+ expect(extractSnippets(md("~~~tsx", "const a = 1;", "~~~"))[0]?.text).toBe("const a = 1;");
102
+ });
103
+ });
104
+
105
+ describe("extractSnippets on a fence that never closes", () => {
106
+ it("reports the opening line and a stable code", () => {
107
+ const doc = md("# Skill", "", `${FENCE}tsx`, "const a = 1;", "", "## Notes", "Prose, not code.");
108
+ try {
109
+ extractSnippets(doc);
110
+ throw new Error("expected extractSnippets to throw");
111
+ } catch (error) {
112
+ expect(error).toBeInstanceOf(UnclosedFenceError);
113
+ expect((error as UnclosedFenceError).line).toBe(3);
114
+ expect((error as UnclosedFenceError).code).toBe("SNIPPET_FENCE_UNCLOSED");
115
+ expect((error as UnclosedFenceError).message).toContain("line 3");
116
+ }
117
+ });
118
+
119
+ // Checked for every language, not just the two this extracts: an open fence
120
+ // consumes the fences below it, so a `bash` one left open would cost the tsx
121
+ // samples after it and the file would pass on nothing.
122
+ it("rejects one in a language it does not check", () => {
123
+ expect(() => extractSnippets(md(`${FENCE}bash`, "npm i", "", "## Notes"))).toThrow(UnclosedFenceError);
124
+ });
125
+
126
+ it("says how long the unclosed delimiter was", () => {
127
+ expect(() => extractSnippets(md("~~~tsx", "const a = 1;"))).toThrow(/opened with 3 tildes/);
128
+ });
129
+
130
+ it("counts a longer closing delimiter as closing", () => {
131
+ expect(extractSnippets(md(`${FENCE}tsx`, "const a = 1;", "````"))).toHaveLength(1);
132
+ });
133
+ });
134
+
135
+ describe("extractSnippets modes", () => {
136
+ it("defaults when the fence carries no marker", () => {
137
+ expect(extractSnippets(md(`${FENCE}tsx`, "const a = 1;", FENCE))[0]?.mode).toBe("default");
138
+ });
139
+
140
+ it("reads complete and no-check from the info string", () => {
141
+ const doc = md(
142
+ `${FENCE}tsx rsg-skill=complete`,
143
+ "export const a = 1;",
144
+ FENCE,
145
+ "",
146
+ `${FENCE}ts rsg-skill=no-check`,
147
+ "this is deliberately wrong",
148
+ FENCE
149
+ );
150
+ expect(extractSnippets(doc).map(snippet => snippet.mode)).toEqual(["complete", "no-check"]);
151
+ });
152
+
153
+ it("ignores info-string words that are not the marker", () => {
154
+ expect(extractSnippets(md(`${FENCE}tsx interop`, "const a = 1;", FENCE))[0]?.mode).toBe("default");
155
+ });
156
+
157
+ it("still returns no-check snippets, so the caller decides what to skip", () => {
158
+ expect(extractSnippets(md(`${FENCE}tsx rsg-skill=no-check`, "const a = 1;", FENCE))).toHaveLength(1);
159
+ });
160
+ });
161
+
162
+ describe("extractSnippets marker errors", () => {
163
+ it("rejects an unrecognised marker value rather than defaulting", () => {
164
+ const doc = md("# Title", "", `${FENCE}tsx rsg-skill=skip`, "const a = 1;", FENCE);
165
+ expect(() => extractSnippets(doc)).toThrow(SnippetMarkerError);
166
+ expect(() => extractSnippets(doc)).toThrow(/rsg-skill=skip/);
167
+ });
168
+
169
+ it("rejects a marker with no value", () => {
170
+ expect(() => extractSnippets(md(`${FENCE}tsx rsg-skill`, "const a = 1;", FENCE))).toThrow(/needs a value/);
171
+ });
172
+
173
+ it("rejects a marker whose value differs only in case", () => {
174
+ expect(() => extractSnippets(md(`${FENCE}tsx rsg-skill=Complete`, "const a = 1;", FENCE))).toThrow(
175
+ SnippetMarkerError
176
+ );
177
+ });
178
+
179
+ it("rejects a fence carrying two markers", () => {
180
+ const doc = md(`${FENCE}tsx rsg-skill=complete rsg-skill=no-check`, "const a = 1;", FENCE);
181
+ expect(() => extractSnippets(doc)).toThrow(/more than one/);
182
+ });
183
+
184
+ it("reports the line of the offending fence and a stable code", () => {
185
+ const doc = md("# Title", "", "Prose.", "", `${FENCE}tsx rsg-skill=skip`, "const a = 1;", FENCE);
186
+ try {
187
+ extractSnippets(doc);
188
+ throw new Error("expected extractSnippets to throw");
189
+ } catch (error) {
190
+ expect(error).toBeInstanceOf(SnippetMarkerError);
191
+ expect((error as SnippetMarkerError).line).toBe(5);
192
+ expect((error as SnippetMarkerError).code).toBe("SNIPPET_MARKER_INVALID");
193
+ expect((error as SnippetMarkerError).message).toContain("line 5");
194
+ }
195
+ });
196
+
197
+ it("does not inspect markers on fences in other languages", () => {
198
+ expect(() => extractSnippets(md(`${FENCE}bash rsg-skill=nonsense`, "npm i", FENCE))).not.toThrow();
199
+ });
200
+ });
201
+
202
+ describe("extractSnippets on the shipped skills", () => {
203
+ // Asserted as properties of whatever the skill ships rather than as a fence
204
+ // count: how many samples an author writes is an authoring decision that
205
+ // changes with the prose, while the invariant this test exists for — a
206
+ // reported `startLine` that really is where the sample starts, which is what
207
+ // makes a snippet diagnostic navigable — holds for any number of them.
208
+ it("maps every sample in a shipped skill back to its real line", () => {
209
+ const path = resolve(PACKAGE_ROOT, "skills", "focus-management", "SKILL.md");
210
+ const source = readFileSync(path, "utf8");
211
+ const lines = source.split("\n");
212
+ const snippets = extractSnippets(source);
213
+
214
+ expect(snippets.length).toBeGreaterThan(0);
215
+ for (const snippet of snippets) {
216
+ expect(snippet.lang).toBe("tsx");
217
+ expect(lines[snippet.startLine - 1]).toBe(snippet.text.split("\n")[0]);
218
+ }
219
+ expect(snippets.map(snippet => snippet.text).join("\n")).toContain("@roku-sdk/navigation");
220
+ });
221
+ });
@@ -0,0 +1,77 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { describe, expect, it } from "@jest/globals";
5
+ import { readFileSync } from "fs";
6
+ import { resolve } from "path";
7
+
8
+ import { loadManifest, type SkillManifest } from "../manifest";
9
+ import { buildRouter, findCollisions, findCollisionsAcross, route, TriggerCollisionError } from "../router";
10
+
11
+ const PACKAGE_ROOT = resolve(__dirname, "..", "..");
12
+
13
+ function shippedManifest(): SkillManifest {
14
+ return loadManifest(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "skills-manifest.json"), "utf8")));
15
+ }
16
+
17
+ /** A synthetic entry. Routing reads only `name` and `triggers`. */
18
+ function skill(name: string, triggers: string[]): SkillManifest["skills"][number] {
19
+ return { name, description: `Use when working with ${name}.`, triggers, path: `skills/${name}/SKILL.md` };
20
+ }
21
+
22
+ describe("trigger routing — shipped manifest routes cleanly", () => {
23
+ it("has no trigger collisions", () => {
24
+ expect(findCollisions(shippedManifest())).toEqual([]);
25
+ });
26
+
27
+ it("routes a known keyword to the expected skill", () => {
28
+ const router = buildRouter(shippedManifest());
29
+ expect(router.route("focus")).toBe("focus-management");
30
+ expect(router.route("FocusBoundary")).toBe("focus-management");
31
+ });
32
+
33
+ it("routes case-insensitively", () => {
34
+ expect(route(shippedManifest(), "FOCUS")).toBe("focus-management");
35
+ });
36
+
37
+ it("returns null for a query with no matching trigger", () => {
38
+ expect(buildRouter(shippedManifest()).route("nonexistent-keyword")).toBeNull();
39
+ });
40
+
41
+ it("exposes the normalized trigger → skill map via entries()", () => {
42
+ const entries = buildRouter(shippedManifest()).entries();
43
+ expect(entries.get("usefocusable")).toBe("focus-management");
44
+ });
45
+ });
46
+
47
+ describe("trigger routing — collision detection", () => {
48
+ const collidingManifest: SkillManifest = {
49
+ version: 1,
50
+ skills: [skill("focus-management", ["focus", "navigation"]), skill("screen-navigation", ["navigation"])]
51
+ };
52
+
53
+ it("reports a synthetic collision via findCollisions", () => {
54
+ const collisions = findCollisions(collidingManifest);
55
+ expect(collisions).toHaveLength(1);
56
+ expect(collisions[0].trigger).toBe("navigation");
57
+ expect(collisions[0].skills).toEqual(["focus-management", "screen-navigation"]);
58
+ });
59
+
60
+ it("buildRouter throws TriggerCollisionError on a synthetic collision", () => {
61
+ expect(() => buildRouter(collidingManifest)).toThrow(TriggerCollisionError);
62
+ });
63
+
64
+ it("detects collisions across multiple manifests", () => {
65
+ const a: SkillManifest = { version: 1, skills: [skill("focus-management", ["focus"])] };
66
+ const b: SkillManifest = { version: 1, skills: [skill("key-handling", ["Focus"])] };
67
+ const collisions = findCollisionsAcross([a, b]);
68
+ expect(collisions).toHaveLength(1);
69
+ expect(collisions[0].trigger).toBe("focus");
70
+ expect(collisions[0].skills).toEqual(["focus-management", "key-handling"]);
71
+ });
72
+
73
+ it("does not treat a repeated trigger within one skill as a collision", () => {
74
+ const manifest: SkillManifest = { version: 1, skills: [skill("focus-management", ["focus", "Focus"])] };
75
+ expect(findCollisions(manifest)).toEqual([]);
76
+ });
77
+ });
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * @module @roku-sdk/ai
6
+ * Public surface: manifest loading/validation and the trigger router.
7
+ */
8
+
9
+ export {
10
+ loadManifest,
11
+ ManifestValidationError,
12
+ zSkillEntry,
13
+ zSkillManifest,
14
+ type SkillEntry,
15
+ type SkillManifest
16
+ } from "./manifest";
17
+
18
+ export {
19
+ buildRouter,
20
+ findCollisions,
21
+ findCollisionsAcross,
22
+ route,
23
+ TriggerCollisionError,
24
+ type Router,
25
+ type TriggerCollision
26
+ } from "./router";
27
+
28
+ export {
29
+ extractSnippets,
30
+ SnippetExtractionError,
31
+ SnippetMarkerError,
32
+ UnclosedFenceError,
33
+ type SkillSnippet,
34
+ type SnippetExtractionCode,
35
+ type SnippetMode
36
+ } from "./snippets";
@@ -0,0 +1,215 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * @module manifest
6
+ * Types, zod schema, and loader for `skills-manifest.json`.
7
+ *
8
+ * The zod schema is the runtime authority; the shipped `skills-manifest.schema.json`
9
+ * gives editors/CI a static contract that mirrors it. This module is pure — no
10
+ * filesystem or network access.
11
+ *
12
+ * The mirror is deliberately one-directional about unknown keys. The JSON Schema
13
+ * rejects them, because an author who writes a key that does nothing wants to be
14
+ * told while editing. This schema ignores them, because manifests are discovered
15
+ * across independently-versioned `@roku-sdk/*` packages: a newer package may ship
16
+ * a field an older loader has never heard of, and refusing to load would fail a
17
+ * whole run over a field that run does not need.
18
+ */
19
+
20
+ import { z } from "zod";
21
+
22
+ /** kebab-case skill name; MUST equal the skill's directory name. */
23
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
24
+
25
+ /** POSIX path relative to the package root, resolving to a `SKILL.md`. */
26
+ const PATH_PATTERN = /^skills\/[^/]+\/SKILL\.md$/;
27
+
28
+ /**
29
+ * POSIX path relative to the package root, resolving to a reference document
30
+ * that loads on demand instead of riding in the body.
31
+ *
32
+ * Flat by design: the generated skill directory has one `references/` level, so
33
+ * a nested source path would either collapse two files onto one output name or
34
+ * force the output to mirror a hierarchy no reader benefits from.
35
+ */
36
+ const REFERENCE_PATH_PATTERN = /^skills\/[^/]+\/references\/[^/]+\.md$/;
37
+
38
+ /** Captures the skill directory from a `path`, to check `references` sit beside it. */
39
+ const SKILL_DIR_PATTERN = /^skills\/([^/]+)\//;
40
+
41
+ /** Matches any line break — activation descriptions must stay on one line. */
42
+ const SINGLE_LINE_PATTERN = /[\r\n]/;
43
+
44
+ /**
45
+ * One manifest entry per skill.
46
+ */
47
+ export interface SkillEntry {
48
+ /** kebab-case; MUST equal the skill's directory name; unique within a manifest. */
49
+ name: string;
50
+ /**
51
+ * Single-line activation prose, surfaced verbatim as the generated skill's
52
+ * `description` frontmatter.
53
+ *
54
+ * This is the field an agent runtime matches on to decide whether to load the
55
+ * skill at all, so it is authored rather than derived: a description built by
56
+ * joining `triggers` reads as a keyword list and steers activation poorly.
57
+ * `triggers` remain the input to our own router, which is a separate concern.
58
+ */
59
+ description: string;
60
+ /** Non-empty; each trigger should be unique across all discovered manifests. */
61
+ triggers: string[];
62
+ /** POSIX path relative to the package root; MUST resolve to an existing SKILL.md. */
63
+ path: string;
64
+ /**
65
+ * Documents delivered alongside the body, for depth the body should not
66
+ * carry: the body is re-sent on every turn once the description matches,
67
+ * while a reference is read only when the task needs it.
68
+ *
69
+ * Declared rather than discovered from the directory, because this list is
70
+ * what the packaging check asserts is in the tarball and what the generator
71
+ * writes — a file present on disk but absent here would ship unwritten, and
72
+ * the body's pointer to it would resolve to nothing.
73
+ *
74
+ * Optional: a skill whose subject fits in one body declares none.
75
+ */
76
+ references?: string[];
77
+ }
78
+
79
+ /**
80
+ * The `skills-manifest.json` document.
81
+ */
82
+ export interface SkillManifest {
83
+ /** Schema/format version of the manifest itself. */
84
+ version: 1;
85
+ skills: SkillEntry[];
86
+ }
87
+
88
+ export const zSkillEntry = z.object({
89
+ name: z.string().regex(NAME_PATTERN, "name must be kebab-case (matching the skill directory name)"),
90
+ // The 1024-character ceiling and the single-line rule are the Agent Skills
91
+ // frontmatter contract; a description violating either is rejected by the
92
+ // runtimes we generate for, so it is cheaper to fail here than downstream.
93
+ description: z
94
+ .string()
95
+ .min(1, "description is required")
96
+ .max(1024, "description must be at most 1024 characters")
97
+ .refine(value => !SINGLE_LINE_PATTERN.test(value), "description must be a single line"),
98
+ triggers: z
99
+ .array(z.string().min(1, "triggers must be non-empty strings"))
100
+ .nonempty("at least one trigger is required"),
101
+ path: z.string().regex(PATH_PATTERN, "path must be skills/<name>/SKILL.md"),
102
+ // Non-empty when present, matching the JSON Schema mirror: `references: []`
103
+ // reads as a claim the author meant to fill in and would otherwise pass here
104
+ // while failing an editor's validation.
105
+ references: z
106
+ .array(z.string().regex(REFERENCE_PATH_PATTERN, "each reference must be skills/<name>/references/<file>.md"))
107
+ .nonempty("references must not be empty when present")
108
+ .optional()
109
+ });
110
+
111
+ /**
112
+ * Report the cross-field rules on `references` that no single-field schema can
113
+ * express: a reference must sit beside the `SKILL.md` it belongs to, and the
114
+ * list must not repeat one.
115
+ *
116
+ * Both failures are otherwise silent and downstream. A reference under another
117
+ * skill's directory still matches the pattern, but the generator names its
118
+ * output from the *owning* skill, so the file would be emitted into a directory
119
+ * whose manifest entry never mentioned it — and pruning, which works by
120
+ * directory, would then fight the other skill for it. A duplicate entry plans
121
+ * the same write twice.
122
+ *
123
+ * Issues are raised at `skills.<i>.references.<j>` so the message reads like
124
+ * every other one this loader produces.
125
+ */
126
+ function checkReferences(entry: z.infer<typeof zSkillEntry>, index: number, ctx: z.RefinementCtx): void {
127
+ const references = entry.references;
128
+ if (references === undefined) {
129
+ return;
130
+ }
131
+
132
+ const skillDir = SKILL_DIR_PATTERN.exec(entry.path)?.[1];
133
+ const seen = new Set<string>();
134
+
135
+ references.forEach((reference, refIndex) => {
136
+ const at = ["skills", index, "references", refIndex];
137
+ if (skillDir !== undefined && !reference.startsWith(`skills/${skillDir}/references/`)) {
138
+ ctx.addIssue({
139
+ code: z.ZodIssueCode.custom,
140
+ path: at,
141
+ message: `reference must sit under skills/${skillDir}/references/, beside the SKILL.md that declares it`
142
+ });
143
+ }
144
+ if (seen.has(reference)) {
145
+ ctx.addIssue({
146
+ code: z.ZodIssueCode.custom,
147
+ path: at,
148
+ message: `duplicate reference ${reference}`
149
+ });
150
+ }
151
+ seen.add(reference);
152
+ });
153
+ }
154
+
155
+ export const zSkillManifest = z
156
+ .object({
157
+ // Deliberately still 1, although making `description` required is a breaking
158
+ // change to the shape. A bump buys no compatibility: this is a strict literal,
159
+ // so version 2 would reject every version-1 document instead of accepting it,
160
+ // and there is no version-1 behavior worth preserving — an entry with no
161
+ // description cannot produce valid Agent Skills frontmatter, which is the only
162
+ // thing a manifest exists to feed. What makes that safe is the consumer set:
163
+ // discovery reads manifests only from installed `@roku-sdk/*` packages, so every
164
+ // manifest the pipeline sees ships alongside this loader and moves with it.
165
+ version: z.literal(1),
166
+ skills: z.array(zSkillEntry).nonempty("at least one skill is required")
167
+ })
168
+ .superRefine((manifest, ctx) => {
169
+ manifest.skills.forEach((entry, index) => checkReferences(entry, index, ctx));
170
+ });
171
+
172
+ /**
173
+ * Thrown by {@link loadManifest} when the input does not satisfy the schema.
174
+ *
175
+ * Note: `@roku-sdk/ai` intentionally has no dependency on `@roku-sdk/tooling`
176
+ * (the dependency is strictly one-way, tooling → ai), so this error extends the
177
+ * native `Error` rather than tooling's `ToolingError`. Consumers in tooling may
178
+ * wrap it to attach a shell exit code.
179
+ */
180
+ export class ManifestValidationError extends Error {
181
+ /** Semantic code for logging / consumers. */
182
+ readonly code = "MANIFEST_INVALID";
183
+ /** The underlying zod issues, for programmatic inspection. */
184
+ readonly issues: z.ZodIssue[];
185
+
186
+ constructor(message: string, issues: z.ZodIssue[]) {
187
+ super(message);
188
+ this.name = "ManifestValidationError";
189
+ this.issues = issues;
190
+ }
191
+ }
192
+
193
+ function formatIssues(issues: z.ZodIssue[]): string {
194
+ return issues
195
+ .map(issue => {
196
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
197
+ return ` - ${path}: ${issue.message}`;
198
+ })
199
+ .join("\n");
200
+ }
201
+
202
+ /**
203
+ * Validate raw JSON and return a typed {@link SkillManifest}.
204
+ *
205
+ * @throws {ManifestValidationError} with a descriptive, path-annotated message
206
+ * when the input does not satisfy the schema.
207
+ */
208
+ export function loadManifest(json: unknown): SkillManifest {
209
+ const result = zSkillManifest.safeParse(json);
210
+ if (!result.success) {
211
+ const details = formatIssues(result.error.issues);
212
+ throw new ManifestValidationError(`Invalid skills manifest:\n${details}`, result.error.issues);
213
+ }
214
+ return result.data;
215
+ }