@nebutra/play-loader 0.1.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.
- package/.nebutra/debug/play-loader.jsonl +7 -0
- package/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/LICENSE +676 -0
- package/README.md +20 -0
- package/dist/chunk-7JPGPQKB.js +233 -0
- package/dist/chunk-7JPGPQKB.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +71 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/examples/chain-plays.ts +11 -0
- package/examples/local-registry.ts +14 -0
- package/examples/parse-play.ts +18 -0
- package/package.json +55 -0
- package/src/cli.ts +56 -0
- package/src/index.test.ts +99 -0
- package/src/index.ts +310 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { PlayLoader, parsePlayMarkdown, resolvePlayChain } from "./index";
|
|
6
|
+
|
|
7
|
+
let root: string | undefined;
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
if (root) await rm(root, { recursive: true, force: true });
|
|
11
|
+
root = undefined;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const playMarkdown = `---
|
|
15
|
+
name: launch_page
|
|
16
|
+
kind: play
|
|
17
|
+
version: 1.0.0
|
|
18
|
+
description: Build a launch page
|
|
19
|
+
inputs:
|
|
20
|
+
idea: { type: string }
|
|
21
|
+
outputs:
|
|
22
|
+
url: { type: string }
|
|
23
|
+
budget:
|
|
24
|
+
duration_s: 60
|
|
25
|
+
required_skills:
|
|
26
|
+
- content_store.write
|
|
27
|
+
- tool_protocol.call
|
|
28
|
+
sub_agents:
|
|
29
|
+
- role: researcher
|
|
30
|
+
allowed_skills: [content_store.search]
|
|
31
|
+
depends_on_plays:
|
|
32
|
+
- play: brand_profile
|
|
33
|
+
outputs: [BRAND.md]
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## What this play does
|
|
37
|
+
|
|
38
|
+
Create a launch page from a compact brief.
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
describe("play-loader", () => {
|
|
42
|
+
it("parses play metadata from SKILL.md without inventing a second file format", () => {
|
|
43
|
+
const play = parsePlayMarkdown(playMarkdown);
|
|
44
|
+
|
|
45
|
+
expect(play.meta).toMatchObject({
|
|
46
|
+
name: "launch_page",
|
|
47
|
+
kind: "play",
|
|
48
|
+
version: "1.0.0",
|
|
49
|
+
description: "Build a launch page",
|
|
50
|
+
});
|
|
51
|
+
expect(play.requiredSkills).toEqual(["content_store.write", "tool_protocol.call"]);
|
|
52
|
+
expect(play.subAgents).toEqual([
|
|
53
|
+
{ role: "researcher", allowedSkills: ["content_store.search"] },
|
|
54
|
+
]);
|
|
55
|
+
expect(play.dependsOnPlays).toEqual([{ play: "brand_profile", outputs: ["BRAND.md"] }]);
|
|
56
|
+
expect(play.body).toContain("Create a launch page");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects non-play SKILL.md documents with a fix suggestion", () => {
|
|
60
|
+
const markdown = playMarkdown.replace("kind: play", "kind: skill");
|
|
61
|
+
expect(() => parsePlayMarkdown(markdown)).toThrow(/kind: play/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("orders play dependencies and rejects cycles", () => {
|
|
65
|
+
const chain = resolvePlayChain([
|
|
66
|
+
{ name: "pitch", dependsOn: ["brand", "demo"] },
|
|
67
|
+
{ name: "brand", dependsOn: [] },
|
|
68
|
+
{ name: "demo", dependsOn: ["brand"] },
|
|
69
|
+
]);
|
|
70
|
+
expect(chain).toEqual(["brand", "demo", "pitch"]);
|
|
71
|
+
|
|
72
|
+
expect(() =>
|
|
73
|
+
resolvePlayChain([
|
|
74
|
+
{ name: "a", dependsOn: ["b"] },
|
|
75
|
+
{ name: "b", dependsOn: ["a"] },
|
|
76
|
+
]),
|
|
77
|
+
).toThrow(/cycle/i);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("lists and tests local plays from a portable root", async () => {
|
|
81
|
+
root = await mkdtemp(join(tmpdir(), "play-loader-"));
|
|
82
|
+
const loader = await PlayLoader.open(root);
|
|
83
|
+
await loader.writePlay("launch_page", playMarkdown);
|
|
84
|
+
|
|
85
|
+
await expect(loader.list()).resolves.toEqual([
|
|
86
|
+
expect.objectContaining({ name: "launch_page", kind: "play" }),
|
|
87
|
+
]);
|
|
88
|
+
await expect(loader.test("launch_page")).resolves.toMatchObject({
|
|
89
|
+
name: "launch_page",
|
|
90
|
+
ok: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const source = join(root, "external.md");
|
|
94
|
+
await writeFile(source, playMarkdown.replaceAll("launch_page", "launch_page_copy"), "utf8");
|
|
95
|
+
await expect(loader.install(`file:${source}`)).resolves.toMatchObject({
|
|
96
|
+
meta: { name: "launch_page_copy" },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { CapabilityError } from "@nebutra/errors";
|
|
4
|
+
import { parseSkillFrontmatter, parseSkillMarkdown, type SkillMeta } from "@nebutra/tool-registry";
|
|
5
|
+
|
|
6
|
+
export interface PlayDependency {
|
|
7
|
+
readonly play: string;
|
|
8
|
+
readonly outputs: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PlaySubagent {
|
|
12
|
+
readonly role: string;
|
|
13
|
+
readonly allowedSkills: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PlayMeta extends SkillMeta {
|
|
17
|
+
readonly kind: "play";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LoadedPlay {
|
|
21
|
+
readonly meta: PlayMeta;
|
|
22
|
+
readonly body: string;
|
|
23
|
+
readonly requiredSkills: readonly string[];
|
|
24
|
+
readonly subAgents: readonly PlaySubagent[];
|
|
25
|
+
readonly dependsOnPlays: readonly PlayDependency[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlayNode {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly dependsOn: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlayTestReport {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly ok: boolean;
|
|
36
|
+
readonly checks: readonly string[];
|
|
37
|
+
readonly suggestion?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PlayLoaderDoctorReport {
|
|
41
|
+
readonly ok: boolean;
|
|
42
|
+
readonly plays: number;
|
|
43
|
+
readonly root: string;
|
|
44
|
+
readonly suggestion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function walk(dir: string): Promise<string[]> {
|
|
48
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
49
|
+
const files: string[] = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const full = join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) files.push(...(await walk(full)));
|
|
53
|
+
else files.push(full);
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asString(value: unknown): string | undefined {
|
|
59
|
+
return typeof value === "string" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asStringArray(value: unknown): string[] {
|
|
63
|
+
return Array.isArray(value)
|
|
64
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
65
|
+
: [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
69
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
70
|
+
? (value as Record<string, unknown>)
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizePlayName(name: string): string {
|
|
75
|
+
return name
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9_-]+/g, "_")
|
|
79
|
+
.replace(/^_+|_+$/g, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function playDebugPath(): string {
|
|
83
|
+
return join(process.cwd(), ".nebutra", "debug", "play-loader.jsonl");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function appendPlayDebug(entry: Record<string, unknown>): Promise<void> {
|
|
87
|
+
const path = playDebugPath();
|
|
88
|
+
await mkdir(dirname(path), { recursive: true });
|
|
89
|
+
await writeFile(path, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, {
|
|
90
|
+
flag: "a",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function readPlayDebug(limit = 10): Promise<unknown[]> {
|
|
95
|
+
try {
|
|
96
|
+
const raw = await readFile(playDebugPath(), "utf8");
|
|
97
|
+
return raw
|
|
98
|
+
.trim()
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.slice(-limit)
|
|
102
|
+
.map((line) => JSON.parse(line) as unknown);
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseSubagents(value: unknown): PlaySubagent[] {
|
|
109
|
+
if (!Array.isArray(value)) return [];
|
|
110
|
+
return value.flatMap((item): PlaySubagent[] => {
|
|
111
|
+
const record = asRecord(item);
|
|
112
|
+
const role = asString(record?.role);
|
|
113
|
+
if (!record || !role) return [];
|
|
114
|
+
return [{ role, allowedSkills: asStringArray(record.allowed_skills) }];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parsePlayDependencies(value: unknown): PlayDependency[] {
|
|
119
|
+
if (!Array.isArray(value)) return [];
|
|
120
|
+
return value.flatMap((item): PlayDependency[] => {
|
|
121
|
+
if (typeof item === "string") return [{ play: item, outputs: [] }];
|
|
122
|
+
const record = asRecord(item);
|
|
123
|
+
const play = asString(record?.play);
|
|
124
|
+
if (!record || !play) return [];
|
|
125
|
+
return [{ play, outputs: asStringArray(record.outputs) }];
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parsePlayMarkdown(markdown: string): LoadedPlay {
|
|
130
|
+
const loaded = parseSkillMarkdown(markdown);
|
|
131
|
+
const { frontmatter } = parseSkillFrontmatter(markdown);
|
|
132
|
+
if (frontmatter.kind !== "play") {
|
|
133
|
+
throw new CapabilityError("play-loader", "Play SKILL.md must set kind: play", {
|
|
134
|
+
suggestion: "Set `kind: play` in frontmatter or keep this document in tool-registry only.",
|
|
135
|
+
statusCode: 400,
|
|
136
|
+
metadata: { name: loaded.meta.name, kind: frontmatter.kind },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
meta: { ...loaded.meta, kind: "play" },
|
|
142
|
+
body: loaded.body,
|
|
143
|
+
requiredSkills: asStringArray(frontmatter.required_skills),
|
|
144
|
+
subAgents: parseSubagents(frontmatter.sub_agents),
|
|
145
|
+
dependsOnPlays: parsePlayDependencies(frontmatter.depends_on_plays),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolvePlayChain(nodes: readonly PlayNode[]): readonly string[] {
|
|
150
|
+
const byName = new Map(nodes.map((node) => [node.name, node]));
|
|
151
|
+
const visited = new Set<string>();
|
|
152
|
+
const visiting = new Set<string>();
|
|
153
|
+
const ordered: string[] = [];
|
|
154
|
+
|
|
155
|
+
const visit = (name: string): void => {
|
|
156
|
+
if (visited.has(name)) return;
|
|
157
|
+
if (visiting.has(name)) {
|
|
158
|
+
throw new CapabilityError("play-loader", "Play dependency cycle detected", {
|
|
159
|
+
suggestion: "Remove the cycle or merge mutually dependent plays into one declaration.",
|
|
160
|
+
statusCode: 400,
|
|
161
|
+
metadata: { name },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
visiting.add(name);
|
|
165
|
+
const node = byName.get(name);
|
|
166
|
+
for (const dependency of node?.dependsOn ?? []) visit(dependency);
|
|
167
|
+
visiting.delete(name);
|
|
168
|
+
visited.add(name);
|
|
169
|
+
ordered.push(name);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const node of nodes) visit(node.name);
|
|
173
|
+
return ordered;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class PlayLoader {
|
|
177
|
+
readonly #root: string;
|
|
178
|
+
readonly #plays = new Map<string, { readonly meta: PlayMeta; readonly path: string }>();
|
|
179
|
+
|
|
180
|
+
private constructor(root: string) {
|
|
181
|
+
this.#root = root;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
static async open(root: string): Promise<PlayLoader> {
|
|
185
|
+
const loader = new PlayLoader(root);
|
|
186
|
+
await mkdir(loader.playsRoot(), { recursive: true });
|
|
187
|
+
await loader.reload();
|
|
188
|
+
return loader;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
playsRoot(): string {
|
|
192
|
+
return join(this.#root, "plays");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async writePlay(name: string, markdown: string): Promise<void> {
|
|
196
|
+
const normalized = normalizePlayName(name);
|
|
197
|
+
const parsed = parsePlayMarkdown(markdown);
|
|
198
|
+
if (parsed.meta.name !== normalized) {
|
|
199
|
+
throw new CapabilityError("play-loader", "Play name does not match path", {
|
|
200
|
+
suggestion: "Use the same lowercase codename in the path and SKILL.md frontmatter.",
|
|
201
|
+
metadata: { pathName: normalized, playName: parsed.meta.name },
|
|
202
|
+
statusCode: 400,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const path = join(this.playsRoot(), normalized, "SKILL.md");
|
|
206
|
+
await mkdir(dirname(path), { recursive: true });
|
|
207
|
+
await writeFile(path, markdown, "utf8");
|
|
208
|
+
await this.reload();
|
|
209
|
+
await appendPlayDebug({ type: "write", play: normalized });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async newPlay(name: string): Promise<LoadedPlay> {
|
|
213
|
+
const normalized = normalizePlayName(name);
|
|
214
|
+
const markdown = [
|
|
215
|
+
"---",
|
|
216
|
+
`name: ${normalized}`,
|
|
217
|
+
"kind: play",
|
|
218
|
+
"version: 1.0.0",
|
|
219
|
+
`description: ${normalized.replaceAll("_", " ")} play`,
|
|
220
|
+
"required_skills: []",
|
|
221
|
+
"sub_agents: []",
|
|
222
|
+
"depends_on_plays: []",
|
|
223
|
+
"---",
|
|
224
|
+
"",
|
|
225
|
+
"## What this play does",
|
|
226
|
+
"",
|
|
227
|
+
"Describe the user story and expected output.",
|
|
228
|
+
].join("\n");
|
|
229
|
+
await this.writePlay(normalized, markdown);
|
|
230
|
+
return this.load(normalized);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async reload(): Promise<void> {
|
|
234
|
+
this.#plays.clear();
|
|
235
|
+
for (const file of await walk(this.playsRoot())) {
|
|
236
|
+
if (!file.endsWith("SKILL.md")) continue;
|
|
237
|
+
const raw = await readFile(file, "utf8");
|
|
238
|
+
const play = parsePlayMarkdown(raw);
|
|
239
|
+
this.#plays.set(play.meta.name, { meta: play.meta, path: relative(this.#root, file) });
|
|
240
|
+
}
|
|
241
|
+
await appendPlayDebug({ type: "reload", plays: this.#plays.size });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async list(): Promise<PlayMeta[]> {
|
|
245
|
+
return [...this.#plays.values()]
|
|
246
|
+
.map((entry) => entry.meta)
|
|
247
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async load(name: string): Promise<LoadedPlay> {
|
|
251
|
+
const play = this.#plays.get(normalizePlayName(name));
|
|
252
|
+
if (!play) {
|
|
253
|
+
throw new CapabilityError("play-loader", "Play not found", {
|
|
254
|
+
suggestion: "Run `pnpm play:list` or create it with `pnpm play:new <name>`.",
|
|
255
|
+
metadata: { name },
|
|
256
|
+
statusCode: 404,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const loaded = parsePlayMarkdown(await readFile(join(this.#root, play.path), "utf8"));
|
|
260
|
+
await appendPlayDebug({ type: "load", play: loaded.meta.name });
|
|
261
|
+
return loaded;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async install(source: string): Promise<LoadedPlay> {
|
|
265
|
+
if (!source.startsWith("file:")) {
|
|
266
|
+
throw new CapabilityError("play-loader", "Only file installs are enabled locally", {
|
|
267
|
+
suggestion:
|
|
268
|
+
"Download remote plays through a reviewed distribution workflow, then install with file:<path>.",
|
|
269
|
+
metadata: { source },
|
|
270
|
+
statusCode: 400,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const raw = await readFile(source.slice("file:".length), "utf8");
|
|
274
|
+
const play = parsePlayMarkdown(raw);
|
|
275
|
+
await this.writePlay(play.meta.name, raw);
|
|
276
|
+
return this.load(play.meta.name);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async test(name: string): Promise<PlayTestReport> {
|
|
280
|
+
try {
|
|
281
|
+
const play = await this.load(name);
|
|
282
|
+
return {
|
|
283
|
+
name: play.meta.name,
|
|
284
|
+
ok: play.body.length > 0,
|
|
285
|
+
checks: ["frontmatter", "kind:play", "body", "dependency-dag"],
|
|
286
|
+
...(!play.body ? { suggestion: "Add a markdown body after the frontmatter." } : {}),
|
|
287
|
+
};
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
name,
|
|
291
|
+
ok: false,
|
|
292
|
+
checks: [],
|
|
293
|
+
suggestion:
|
|
294
|
+
error instanceof CapabilityError
|
|
295
|
+
? "Create the play with `pnpm play:new <name>` or fix its SKILL.md metadata."
|
|
296
|
+
: "Run `pnpm play:list` and verify the play directory exists.",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async doctor(): Promise<PlayLoaderDoctorReport> {
|
|
302
|
+
const plays = this.#plays.size;
|
|
303
|
+
return {
|
|
304
|
+
ok: plays > 0,
|
|
305
|
+
plays,
|
|
306
|
+
root: this.#root,
|
|
307
|
+
...(plays === 0 ? { suggestion: "Create a play with `pnpm play:new <name>`." } : {}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"target": "esnext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"incremental": false
|
|
9
|
+
},
|
|
10
|
+
"include": ["src"],
|
|
11
|
+
"exclude": ["node_modules", "dist"]
|
|
12
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ["src/index.ts", "src/cli.ts"],
|
|
5
|
+
format: ["esm"],
|
|
6
|
+
dts: true,
|
|
7
|
+
sourcemap: true,
|
|
8
|
+
clean: true,
|
|
9
|
+
target: "es2022",
|
|
10
|
+
external: ["@nebutra/errors", "@nebutra/tool-registry"],
|
|
11
|
+
});
|