@funkai/cli 0.1.4 → 0.3.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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +81 -0
- package/dist/index.mjs +939 -3484
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
- package/src/commands/generate.ts +8 -1
- package/src/commands/prompts/create.ts +31 -2
- package/src/commands/prompts/generate.ts +91 -35
- package/src/commands/prompts/lint.ts +63 -20
- package/src/commands/prompts/setup.ts +153 -119
- package/src/commands/setup.ts +129 -4
- package/src/commands/validate.ts +8 -1
- package/src/config.ts +28 -0
- package/src/index.ts +4 -0
- package/src/lib/prompts/__tests__/flatten.test.ts +29 -27
- package/src/lib/prompts/__tests__/frontmatter.test.ts +17 -15
- package/src/lib/prompts/codegen.ts +149 -79
- package/src/lib/prompts/extract-variables.ts +20 -9
- package/src/lib/prompts/flatten.ts +62 -16
- package/src/lib/prompts/frontmatter.ts +97 -53
- package/src/lib/prompts/lint.ts +18 -23
- package/src/lib/prompts/paths.ts +90 -24
- package/src/lib/prompts/pipeline.ts +87 -11
|
@@ -10,7 +10,7 @@ describe(flattenPartials, () => {
|
|
|
10
10
|
describe("param parsing", () => {
|
|
11
11
|
it("resolves a single literal param", () => {
|
|
12
12
|
const template = "{% render 'identity', role: 'Bot' %}";
|
|
13
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
13
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
14
14
|
|
|
15
15
|
expect(result).toContain("<identity>");
|
|
16
16
|
expect(result).toContain("You are Bot, .");
|
|
@@ -19,14 +19,14 @@ describe(flattenPartials, () => {
|
|
|
19
19
|
|
|
20
20
|
it("resolves multiple literal params", () => {
|
|
21
21
|
const template = "{% render 'identity', role: 'TestBot', desc: 'a test agent' %}";
|
|
22
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
22
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
23
23
|
|
|
24
24
|
expect(result).toContain("You are TestBot, a test agent.");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("accepts an empty string as a valid literal param value", () => {
|
|
28
28
|
const template = "{% render 'identity', role: '', desc: 'helper' %}";
|
|
29
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
29
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
30
30
|
|
|
31
31
|
expect(result).toContain("You are , helper.");
|
|
32
32
|
expect(result).not.toContain("{% render");
|
|
@@ -35,7 +35,7 @@ describe(flattenPartials, () => {
|
|
|
35
35
|
it("throws when the first param uses a variable reference", () => {
|
|
36
36
|
const template = "{% render 'identity', role: agentRole, desc: 'helper' %}";
|
|
37
37
|
|
|
38
|
-
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
|
|
38
|
+
expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
|
|
39
39
|
'parameter "role" uses a variable reference',
|
|
40
40
|
);
|
|
41
41
|
});
|
|
@@ -43,7 +43,7 @@ describe(flattenPartials, () => {
|
|
|
43
43
|
it("throws when a non-first param uses a variable reference", () => {
|
|
44
44
|
const template = "{% render 'identity', role: 'Bot', desc: myDesc %}";
|
|
45
45
|
|
|
46
|
-
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
|
|
46
|
+
expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
|
|
47
47
|
'parameter "desc" uses a variable reference',
|
|
48
48
|
);
|
|
49
49
|
});
|
|
@@ -51,19 +51,21 @@ describe(flattenPartials, () => {
|
|
|
51
51
|
it("throws when all params are variable references", () => {
|
|
52
52
|
const template = "{% render 'identity', role: agentRole, desc: agentDesc %}";
|
|
53
53
|
|
|
54
|
-
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
|
|
54
|
+
expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
|
|
55
|
+
"uses a variable reference",
|
|
56
|
+
);
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
it("handles extra whitespace around colons in params", () => {
|
|
58
60
|
const template = "{% render 'identity', role : 'Bot', desc : 'helper' %}";
|
|
59
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
61
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
60
62
|
|
|
61
63
|
expect(result).toContain("You are Bot, helper.");
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
it("handles param values containing spaces", () => {
|
|
65
67
|
const template = "{% render 'identity', role: 'Test Bot', desc: 'a helpful assistant' %}";
|
|
66
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
68
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
67
69
|
|
|
68
70
|
expect(result).toContain("You are Test Bot, a helpful assistant.");
|
|
69
71
|
});
|
|
@@ -72,12 +74,12 @@ describe(flattenPartials, () => {
|
|
|
72
74
|
describe("render tag parsing", () => {
|
|
73
75
|
it("returns template unchanged when no render tags exist", () => {
|
|
74
76
|
const template = "<identity>\nYou are a bot.\n</identity>";
|
|
75
|
-
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
77
|
+
expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
it("parses a render tag with no params", () => {
|
|
79
81
|
const template = "{% render 'identity' %}\n\nDone.";
|
|
80
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
82
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
81
83
|
|
|
82
84
|
expect(result).toContain("<identity>");
|
|
83
85
|
expect(result).toContain("</identity>");
|
|
@@ -86,7 +88,7 @@ describe(flattenPartials, () => {
|
|
|
86
88
|
|
|
87
89
|
it("parses left-only whitespace trim {%-", () => {
|
|
88
90
|
const template = "{%- render 'identity', role: 'Bot', desc: 'helper' %}\nDone.";
|
|
89
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
91
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
90
92
|
|
|
91
93
|
expect(result).toContain("You are Bot, helper.");
|
|
92
94
|
expect(result).not.toContain("{%-");
|
|
@@ -94,7 +96,7 @@ describe(flattenPartials, () => {
|
|
|
94
96
|
|
|
95
97
|
it("parses right-only whitespace trim -%}", () => {
|
|
96
98
|
const template = "{% render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
|
|
97
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
99
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
98
100
|
|
|
99
101
|
expect(result).toContain("You are Bot, helper.");
|
|
100
102
|
expect(result).not.toContain("-%}");
|
|
@@ -102,7 +104,7 @@ describe(flattenPartials, () => {
|
|
|
102
104
|
|
|
103
105
|
it("parses both-side whitespace trim {%- -%}", () => {
|
|
104
106
|
const template = "{%- render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
|
|
105
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
107
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
106
108
|
|
|
107
109
|
expect(result).toContain("You are Bot, helper.");
|
|
108
110
|
expect(result).not.toContain("{%");
|
|
@@ -110,19 +112,19 @@ describe(flattenPartials, () => {
|
|
|
110
112
|
|
|
111
113
|
it("handles extra whitespace between {% and render keyword", () => {
|
|
112
114
|
const template = "{% render 'identity', role: 'Bot', desc: 'helper' %}";
|
|
113
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
115
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
114
116
|
|
|
115
117
|
expect(result).toContain("You are Bot, helper.");
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
it("does not match render tags with double quotes", () => {
|
|
119
121
|
const template = '{% render "identity" %}';
|
|
120
|
-
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
122
|
+
expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
it("does not match malformed render tags without closing %}", () => {
|
|
124
126
|
const template = "{% render 'identity'";
|
|
125
|
-
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
127
|
+
expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
|
|
126
128
|
});
|
|
127
129
|
});
|
|
128
130
|
|
|
@@ -130,7 +132,7 @@ describe(flattenPartials, () => {
|
|
|
130
132
|
it("flattens identity partial with literal params", () => {
|
|
131
133
|
const template =
|
|
132
134
|
"{% render 'identity', role: 'TestBot', desc: 'a test agent' %}\n\nFollow instructions.";
|
|
133
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
135
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
134
136
|
|
|
135
137
|
expect(result).toContain("<identity>");
|
|
136
138
|
expect(result).toContain("You are TestBot, a test agent.");
|
|
@@ -141,7 +143,7 @@ describe(flattenPartials, () => {
|
|
|
141
143
|
|
|
142
144
|
it("flattens constraints partial with no bindings", () => {
|
|
143
145
|
const template = "{% render 'constraints' %}";
|
|
144
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
146
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
145
147
|
|
|
146
148
|
expect(result).toContain("<constraints>");
|
|
147
149
|
expect(result).toContain("</constraints>");
|
|
@@ -153,7 +155,7 @@ describe(flattenPartials, () => {
|
|
|
153
155
|
|
|
154
156
|
it("flattens tools partial with no bindings (else branch)", () => {
|
|
155
157
|
const template = "{% render 'tools' %}";
|
|
156
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
158
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
157
159
|
|
|
158
160
|
expect(result).toContain("<tools>");
|
|
159
161
|
expect(result).toContain("</tools>");
|
|
@@ -168,7 +170,7 @@ describe(flattenPartials, () => {
|
|
|
168
170
|
"{% render 'identity', role: 'Agent', desc: 'analyzer' %}",
|
|
169
171
|
].join("\n");
|
|
170
172
|
|
|
171
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
173
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
172
174
|
|
|
173
175
|
expect(result).toContain("You are Bot, helper.");
|
|
174
176
|
expect(result).toContain("You are Agent, analyzer.");
|
|
@@ -178,7 +180,7 @@ describe(flattenPartials, () => {
|
|
|
178
180
|
it("preserves surrounding markdown content", () => {
|
|
179
181
|
const template =
|
|
180
182
|
"# System Prompt\n\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n\n## Instructions\n\nDo the thing.";
|
|
181
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
183
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
182
184
|
|
|
183
185
|
expect(result).toMatch(/^# System Prompt/);
|
|
184
186
|
expect(result).toContain("You are Bot, helper.");
|
|
@@ -188,13 +190,13 @@ describe(flattenPartials, () => {
|
|
|
188
190
|
it("throws when partial file does not exist", () => {
|
|
189
191
|
const template = "{% render 'nonexistent' %}";
|
|
190
192
|
|
|
191
|
-
expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow();
|
|
193
|
+
expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow();
|
|
192
194
|
});
|
|
193
195
|
|
|
194
196
|
it("searches multiple partialsDirs in order", () => {
|
|
195
197
|
const emptyDir = resolve(import.meta.dirname);
|
|
196
198
|
const template = "{% render 'identity', role: 'Bot', desc: 'test' %}";
|
|
197
|
-
const result = flattenPartials(template, [emptyDir, PARTIALS_DIR]);
|
|
199
|
+
const result = flattenPartials({ template, partialsDirs: [emptyDir, PARTIALS_DIR] });
|
|
198
200
|
|
|
199
201
|
expect(result).toContain("You are Bot, test.");
|
|
200
202
|
});
|
|
@@ -203,13 +205,13 @@ describe(flattenPartials, () => {
|
|
|
203
205
|
describe("template preservation", () => {
|
|
204
206
|
it("preserves {{ var }} and {% if %} expressions", () => {
|
|
205
207
|
const template = "Hello {{ name }}.\n{% if context %}{{ context }}{% endif %}";
|
|
206
|
-
expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
|
|
208
|
+
expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
it("flattens render tag while preserving surrounding Liquid blocks", () => {
|
|
210
212
|
const template =
|
|
211
213
|
"{% if show_identity %}\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n{% endif %}\n\n{{ instructions }}";
|
|
212
|
-
const result = flattenPartials(template, [PARTIALS_DIR]);
|
|
214
|
+
const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
|
|
213
215
|
|
|
214
216
|
expect(result).toContain("{% if show_identity %}");
|
|
215
217
|
expect(result).toContain("{% endif %}");
|
|
@@ -219,12 +221,12 @@ describe(flattenPartials, () => {
|
|
|
219
221
|
});
|
|
220
222
|
|
|
221
223
|
it("returns empty string unchanged", () => {
|
|
222
|
-
expect(flattenPartials("", [PARTIALS_DIR])).toBe("");
|
|
224
|
+
expect(flattenPartials({ template: "", partialsDirs: [PARTIALS_DIR] })).toBe("");
|
|
223
225
|
});
|
|
224
226
|
|
|
225
227
|
it("returns whitespace-only template unchanged", () => {
|
|
226
228
|
const ws = " \n\n ";
|
|
227
|
-
expect(flattenPartials(ws, [PARTIALS_DIR])).toBe(ws);
|
|
229
|
+
expect(flattenPartials({ template: ws, partialsDirs: [PARTIALS_DIR] })).toBe(ws);
|
|
228
230
|
});
|
|
229
231
|
});
|
|
230
232
|
});
|
|
@@ -5,20 +5,20 @@ import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
|
|
|
5
5
|
describe(parseFrontmatter, () => {
|
|
6
6
|
it("parses name from frontmatter", () => {
|
|
7
7
|
const content = "---\nname: my-prompt\n---\nHello";
|
|
8
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
8
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
9
9
|
expect(result.name).toBe("my-prompt");
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
it("parses group and version", () => {
|
|
13
13
|
const content = "---\nname: test\ngroup: agents/test\nversion: 2\n---\nBody";
|
|
14
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
14
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
15
15
|
expect(result.group).toBe("agents/test");
|
|
16
16
|
expect(result.version).toBe("2");
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
it("parses schema with shorthand type strings", () => {
|
|
20
20
|
const content = "---\nname: test\nschema:\n scope: string\n target: string\n---\n";
|
|
21
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
21
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
22
22
|
expect(result.schema).toEqual([
|
|
23
23
|
{ name: "scope", type: "string", required: true },
|
|
24
24
|
{ name: "target", type: "string", required: true },
|
|
@@ -39,7 +39,7 @@ describe(parseFrontmatter, () => {
|
|
|
39
39
|
"---",
|
|
40
40
|
"",
|
|
41
41
|
].join("\n");
|
|
42
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
42
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
43
43
|
expect(result.schema).toEqual([
|
|
44
44
|
{ name: "scope", type: "string", required: true, description: "The scope" },
|
|
45
45
|
{ name: "target", type: "string", required: false },
|
|
@@ -48,42 +48,44 @@ describe(parseFrontmatter, () => {
|
|
|
48
48
|
|
|
49
49
|
it("returns empty schema when no schema field", () => {
|
|
50
50
|
const content = "---\nname: test\n---\nBody";
|
|
51
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
51
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
52
52
|
expect(result.schema).toEqual([]);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it("throws on missing frontmatter", () => {
|
|
56
|
-
expect(() => parseFrontmatter("No frontmatter", "test.prompt")).toThrow(
|
|
56
|
+
expect(() => parseFrontmatter({ content: "No frontmatter", filePath: "test.prompt" })).toThrow(
|
|
57
|
+
"No frontmatter",
|
|
58
|
+
);
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
it("throws on missing name", () => {
|
|
60
|
-
expect(() =>
|
|
61
|
-
|
|
62
|
-
);
|
|
62
|
+
expect(() =>
|
|
63
|
+
parseFrontmatter({ content: "---\nversion: 1\n---\n", filePath: "test.prompt" }),
|
|
64
|
+
).toThrow('Missing or empty "name"');
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
it("throws on invalid name format", () => {
|
|
66
|
-
expect(() =>
|
|
67
|
-
"
|
|
68
|
-
);
|
|
68
|
+
expect(() =>
|
|
69
|
+
parseFrontmatter({ content: "---\nname: My Prompt\n---\n", filePath: "test.prompt" }),
|
|
70
|
+
).toThrow("Invalid prompt name");
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
it("returns undefined group when not specified", () => {
|
|
72
74
|
const content = "---\nname: test\n---\nBody";
|
|
73
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
75
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
74
76
|
expect(result.group).toBeUndefined();
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
it("should throw on invalid group segment", () => {
|
|
78
80
|
const content = "---\nname: test\ngroup: agents/INVALID\n---\nBody";
|
|
79
|
-
expect(() => parseFrontmatter(content, "test.prompt")).toThrow(
|
|
81
|
+
expect(() => parseFrontmatter({ content, filePath: "test.prompt" })).toThrow(
|
|
80
82
|
'Invalid group segment "INVALID"',
|
|
81
83
|
);
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
it("should accept valid multi-segment group", () => {
|
|
85
87
|
const content = "---\nname: test\ngroup: agents/specialized\n---\nBody";
|
|
86
|
-
const result = parseFrontmatter(content, "test.prompt");
|
|
88
|
+
const result = parseFrontmatter({ content, filePath: "test.prompt" });
|
|
87
89
|
expect(result.group).toBe("agents/specialized");
|
|
88
90
|
});
|
|
89
91
|
});
|
|
@@ -13,6 +13,10 @@ export interface ParsedPrompt {
|
|
|
13
13
|
readonly sourcePath: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Private
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
21
|
* Convert a kebab-case name to PascalCase.
|
|
18
22
|
*
|
|
@@ -55,6 +59,30 @@ function escapeTemplateLiteral(str: string): string {
|
|
|
55
59
|
.replaceAll("${", "\\${");
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/** @private */
|
|
63
|
+
function formatGroupValue(group: string | undefined): string {
|
|
64
|
+
if (group) {
|
|
65
|
+
return `'${group}' as const`;
|
|
66
|
+
}
|
|
67
|
+
return "undefined";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @private */
|
|
71
|
+
function formatGroupJsdoc(group: string | undefined): readonly string[] {
|
|
72
|
+
if (group) {
|
|
73
|
+
return [" *", ` * @group ${group}`];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @private */
|
|
79
|
+
function parseGroupSegments(group: string | undefined): readonly string[] {
|
|
80
|
+
if (group) {
|
|
81
|
+
return group.split("/").map(toCamelCase);
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
/**
|
|
59
87
|
* Generate the Zod schema expression for a list of schema variables.
|
|
60
88
|
*
|
|
@@ -68,13 +96,9 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
|
|
|
68
96
|
const fields = vars
|
|
69
97
|
.map((v) => {
|
|
70
98
|
const base = "z.string()";
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return base;
|
|
75
|
-
}
|
|
76
|
-
return `${base}.optional()`;
|
|
77
|
-
})();
|
|
99
|
+
const expr = match(v.required)
|
|
100
|
+
.with(true, () => base)
|
|
101
|
+
.otherwise(() => `${base}.optional()`);
|
|
78
102
|
return ` ${v.name}: ${expr},`;
|
|
79
103
|
})
|
|
80
104
|
.join("\n");
|
|
@@ -82,40 +106,79 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
|
|
|
82
106
|
return `z.object({\n${fields}\n})`;
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
/** @private */
|
|
110
|
+
function formatHeader(sourcePath?: string): string {
|
|
111
|
+
let sourceLine = "";
|
|
112
|
+
if (sourcePath) {
|
|
113
|
+
sourceLine = `// Source: ${sourcePath}\n`;
|
|
114
|
+
}
|
|
115
|
+
return [
|
|
116
|
+
"// ─── AUTO-GENERATED ────────────────────────────────────────",
|
|
117
|
+
`${sourceLine}// Regenerate: funkai prompts generate`,
|
|
118
|
+
"// ───────────────────────────────────────────────────────────",
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Derive a unique file slug from group + name.
|
|
124
|
+
*
|
|
125
|
+
* Ungrouped prompts use the name alone. Grouped prompts
|
|
126
|
+
* join group segments and name with hyphens.
|
|
127
|
+
*
|
|
128
|
+
* @param name - The prompt name (kebab-case).
|
|
129
|
+
* @param group - Optional group path (e.g., 'core/agent').
|
|
130
|
+
* @returns The file slug string.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* toFileSlug('system', 'core/agent') // => 'core-agent-system'
|
|
135
|
+
* toFileSlug('greeting', undefined) // => 'greeting'
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function toFileSlug(name: string, group?: string): string {
|
|
139
|
+
if (group) {
|
|
140
|
+
return `${group.replaceAll("/", "-")}-${name}`;
|
|
141
|
+
}
|
|
142
|
+
return name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive a unique import name (camelCase) from group + name.
|
|
147
|
+
*
|
|
148
|
+
* @param name - The prompt name (kebab-case).
|
|
149
|
+
* @param group - Optional group path (e.g., 'core/agent').
|
|
150
|
+
* @returns The camelCase import identifier.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* toImportName('system', 'core/agent') // => 'coreAgentSystem'
|
|
155
|
+
* toImportName('greeting', undefined) // => 'greeting'
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export function toImportName(name: string, group?: string): string {
|
|
159
|
+
return toCamelCase(toFileSlug(name, group));
|
|
160
|
+
}
|
|
95
161
|
|
|
96
162
|
/**
|
|
97
163
|
* Generate a per-prompt TypeScript module with a default export.
|
|
98
164
|
*
|
|
99
|
-
* The module
|
|
100
|
-
*
|
|
165
|
+
* The module uses `createPrompt` from `@funkai/prompts` to
|
|
166
|
+
* encapsulate the Zod schema, inlined template, and render logic.
|
|
167
|
+
*
|
|
168
|
+
* @param prompt - The parsed prompt configuration.
|
|
169
|
+
* @returns The generated TypeScript module source code.
|
|
101
170
|
*/
|
|
102
171
|
export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
103
172
|
const escaped = escapeTemplateLiteral(prompt.template);
|
|
104
173
|
const schemaExpr = generateSchemaExpression(prompt.schema);
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (prompt.group) {
|
|
108
|
-
return `'${prompt.group}' as const`;
|
|
109
|
-
}
|
|
110
|
-
return "undefined";
|
|
111
|
-
})();
|
|
174
|
+
const groupValue = formatGroupValue(prompt.group);
|
|
175
|
+
const header = formatHeader(prompt.sourcePath);
|
|
112
176
|
|
|
113
|
-
const lines: string[] = [
|
|
114
|
-
|
|
115
|
-
`// Source: ${prompt.sourcePath}`,
|
|
177
|
+
const lines: readonly string[] = [
|
|
178
|
+
header,
|
|
116
179
|
"",
|
|
117
180
|
"import { z } from 'zod'",
|
|
118
|
-
"import {
|
|
181
|
+
"import { createPrompt } from '@funkai/prompts'",
|
|
119
182
|
"",
|
|
120
183
|
`const schema = ${schemaExpr}`,
|
|
121
184
|
"",
|
|
@@ -123,28 +186,16 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
|
123
186
|
"",
|
|
124
187
|
`const template = \`${escaped}\``,
|
|
125
188
|
"",
|
|
126
|
-
"
|
|
127
|
-
`
|
|
189
|
+
"/**",
|
|
190
|
+
` * **${prompt.name}** prompt module.`,
|
|
191
|
+
...formatGroupJsdoc(prompt.group),
|
|
192
|
+
" */",
|
|
193
|
+
"export default createPrompt<Variables>({",
|
|
194
|
+
` name: '${prompt.name}',`,
|
|
128
195
|
` group: ${groupValue},`,
|
|
196
|
+
" template,",
|
|
129
197
|
" schema,",
|
|
130
|
-
|
|
131
|
-
.with(0, () => [
|
|
132
|
-
" render(variables?: undefined): string {",
|
|
133
|
-
" return liquidEngine.parseAndRenderSync(template, {})",
|
|
134
|
-
" },",
|
|
135
|
-
" validate(variables?: undefined): Variables {",
|
|
136
|
-
" return schema.parse(variables ?? {})",
|
|
137
|
-
" },",
|
|
138
|
-
])
|
|
139
|
-
.otherwise(() => [
|
|
140
|
-
" render(variables: Variables): string {",
|
|
141
|
-
" return liquidEngine.parseAndRenderSync(template, schema.parse(variables))",
|
|
142
|
-
" },",
|
|
143
|
-
" validate(variables: unknown): Variables {",
|
|
144
|
-
" return schema.parse(variables)",
|
|
145
|
-
" },",
|
|
146
|
-
]),
|
|
147
|
-
"}",
|
|
198
|
+
"})",
|
|
148
199
|
"",
|
|
149
200
|
];
|
|
150
201
|
|
|
@@ -162,6 +213,9 @@ interface TreeNode {
|
|
|
162
213
|
/**
|
|
163
214
|
* Build a nested tree from sorted prompts, grouped by their `group` field.
|
|
164
215
|
*
|
|
216
|
+
* Leaf values are the unique import name derived from group+name,
|
|
217
|
+
* so prompts with the same name in different groups do not collide.
|
|
218
|
+
*
|
|
165
219
|
* @param prompts - Sorted parsed prompts.
|
|
166
220
|
* @returns A tree where leaves are import names and branches are group namespaces.
|
|
167
221
|
* @throws If a prompt name collides with a group namespace at the same level.
|
|
@@ -170,14 +224,9 @@ interface TreeNode {
|
|
|
170
224
|
*/
|
|
171
225
|
function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
172
226
|
return prompts.reduce<Record<string, unknown>>((root, prompt) => {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const segments
|
|
176
|
-
if (prompt.group) {
|
|
177
|
-
return prompt.group.split("/").map(toCamelCase);
|
|
178
|
-
}
|
|
179
|
-
return [];
|
|
180
|
-
})();
|
|
227
|
+
const leafKey = toCamelCase(prompt.name);
|
|
228
|
+
const importName = toImportName(prompt.name, prompt.group);
|
|
229
|
+
const segments = parseGroupSegments(prompt.group);
|
|
181
230
|
|
|
182
231
|
const target = segments.reduce<Record<string, unknown>>((current, segment) => {
|
|
183
232
|
const existing = current[segment];
|
|
@@ -192,13 +241,13 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
|
192
241
|
return current[segment] as Record<string, unknown>;
|
|
193
242
|
}, root);
|
|
194
243
|
|
|
195
|
-
if (typeof target[
|
|
244
|
+
if (typeof target[leafKey] === "object" && target[leafKey] !== null) {
|
|
196
245
|
throw new Error(
|
|
197
|
-
`Collision: prompt "${
|
|
246
|
+
`Collision: prompt "${leafKey}" conflicts with existing group namespace "${leafKey}" at the same level.`,
|
|
198
247
|
);
|
|
199
248
|
}
|
|
200
249
|
|
|
201
|
-
target[
|
|
250
|
+
target[leafKey] = importName;
|
|
202
251
|
return root;
|
|
203
252
|
}, {}) as TreeNode;
|
|
204
253
|
}
|
|
@@ -212,22 +261,23 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
|
212
261
|
*
|
|
213
262
|
* @private
|
|
214
263
|
*/
|
|
215
|
-
function serializeTree(node: TreeNode, indent: number): string[] {
|
|
264
|
+
function serializeTree(node: TreeNode, indent: number): readonly string[] {
|
|
216
265
|
const pad = " ".repeat(indent);
|
|
217
266
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
267
|
+
return Object.entries(node).flatMap(([key, value]) =>
|
|
268
|
+
match(typeof value)
|
|
269
|
+
.with("string", () => {
|
|
270
|
+
if (key === value) {
|
|
271
|
+
return [`${pad}${key},`];
|
|
272
|
+
}
|
|
273
|
+
return [`${pad}${key}: ${value as string},`];
|
|
274
|
+
})
|
|
275
|
+
.otherwise(() => [
|
|
276
|
+
`${pad}${key}: {`,
|
|
277
|
+
...serializeTree(value as TreeNode, indent + 1),
|
|
278
|
+
`${pad}},`,
|
|
279
|
+
]),
|
|
280
|
+
);
|
|
231
281
|
}
|
|
232
282
|
|
|
233
283
|
/**
|
|
@@ -236,19 +286,39 @@ function serializeTree(node: TreeNode, indent: number): string[] {
|
|
|
236
286
|
*
|
|
237
287
|
* Prompts are organized into a nested object structure based on their
|
|
238
288
|
* `group` field, with each `/`-separated segment becoming a nesting level.
|
|
289
|
+
*
|
|
290
|
+
* @param prompts - Sorted parsed prompts to include in the registry.
|
|
291
|
+
* @returns The generated TypeScript source for the registry index module.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```ts
|
|
295
|
+
* const source = generateRegistry([
|
|
296
|
+
* { name: 'system', group: 'core/agent', schema: [], template: '...', sourcePath: 'prompts/system.prompt' },
|
|
297
|
+
* ])
|
|
298
|
+
* writeFileSync('index.ts', source)
|
|
299
|
+
* ```
|
|
239
300
|
*/
|
|
240
301
|
export function generateRegistry(prompts: readonly ParsedPrompt[]): string {
|
|
241
|
-
const sorted = [...prompts].toSorted((a, b) =>
|
|
302
|
+
const sorted = [...prompts].toSorted((a, b) => {
|
|
303
|
+
const slugA = toFileSlug(a.name, a.group);
|
|
304
|
+
const slugB = toFileSlug(b.name, b.group);
|
|
305
|
+
return slugA.localeCompare(slugB);
|
|
306
|
+
});
|
|
242
307
|
|
|
243
308
|
const imports = sorted
|
|
244
|
-
.map((p) =>
|
|
309
|
+
.map((p) => {
|
|
310
|
+
const importName = toImportName(p.name, p.group);
|
|
311
|
+
const fileSlug = toFileSlug(p.name, p.group);
|
|
312
|
+
return `import ${importName} from './${fileSlug}.js'`;
|
|
313
|
+
})
|
|
245
314
|
.join("\n");
|
|
246
315
|
|
|
247
316
|
const tree = buildTree(sorted);
|
|
248
317
|
const treeLines = serializeTree(tree, 1);
|
|
318
|
+
const header = formatHeader();
|
|
249
319
|
|
|
250
|
-
const lines: string[] = [
|
|
251
|
-
|
|
320
|
+
const lines: readonly string[] = [
|
|
321
|
+
header,
|
|
252
322
|
"",
|
|
253
323
|
"import { createPromptRegistry } from '@funkai/prompts'",
|
|
254
324
|
imports,
|