@funkai/cli 0.1.3 → 0.1.4
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 +6 -0
- package/dist/index.mjs +8191 -7405
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/commands/prompts/create.ts +2 -2
- package/src/commands/prompts/generate.ts +9 -4
- package/src/commands/prompts/lint.ts +8 -7
- package/src/commands/prompts/setup.ts +34 -17
- package/src/lib/prompts/__tests__/extract-variables.test.ts +1 -1
- package/src/lib/prompts/__tests__/flatten.test.ts +1 -1
- package/src/lib/prompts/__tests__/frontmatter.test.ts +1 -1
- package/src/lib/prompts/__tests__/lint.test.ts +5 -5
- package/src/lib/prompts/codegen.ts +46 -19
- package/src/lib/prompts/extract-variables.ts +7 -1
- package/src/lib/prompts/flatten.ts +10 -5
- package/src/lib/prompts/frontmatter.ts +37 -23
- package/src/lib/prompts/paths.ts +2 -2
- package/src/lib/prompts/pipeline.ts +21 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@funkai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "CLI for the funkai AI SDK framework",
|
|
6
6
|
"keywords": [
|
|
@@ -25,8 +25,11 @@
|
|
|
25
25
|
"funkai": "./bin/funkai.mjs"
|
|
26
26
|
},
|
|
27
27
|
"type": "module",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
28
31
|
"dependencies": {
|
|
29
|
-
"@kidd-cli/core": "^0.
|
|
32
|
+
"@kidd-cli/core": "^0.10.0",
|
|
30
33
|
"liquidjs": "^10.25.0",
|
|
31
34
|
"ts-pattern": "^5.9.0",
|
|
32
35
|
"yaml": "^2.8.2",
|
|
@@ -34,10 +37,10 @@
|
|
|
34
37
|
"@funkai/prompts": "0.2.0"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
|
-
"@kidd-cli/cli": "^0.4.
|
|
40
|
+
"@kidd-cli/cli": "^0.4.9",
|
|
38
41
|
"@types/node": "^25.5.0",
|
|
39
42
|
"@vitest/coverage-v8": "^4.1.0",
|
|
40
|
-
"tsdown": "^0.21.
|
|
43
|
+
"tsdown": "^0.21.4",
|
|
41
44
|
"typescript": "^5.9.3",
|
|
42
45
|
"vitest": "^4.1.0"
|
|
43
46
|
},
|
|
@@ -5,7 +5,7 @@ import { command } from "@kidd-cli/core";
|
|
|
5
5
|
import { match, P } from "ts-pattern";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const createTemplate = (name: string) => `---
|
|
9
9
|
name: ${name}
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -34,7 +34,7 @@ export default command({
|
|
|
34
34
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: directory derived from user CLI path argument
|
|
35
35
|
mkdirSync(dir, { recursive: true });
|
|
36
36
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: file path derived from user CLI path argument
|
|
37
|
-
writeFileSync(filePath,
|
|
37
|
+
writeFileSync(filePath, createTemplate(name), "utf8");
|
|
38
38
|
|
|
39
39
|
ctx.logger.success(`Created ${filePath}`);
|
|
40
40
|
},
|
|
@@ -50,8 +50,13 @@ export function handleGenerate(
|
|
|
50
50
|
|
|
51
51
|
if (!silent) {
|
|
52
52
|
for (const prompt of prompts) {
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
54
|
+
const varList: string = (() => {
|
|
55
|
+
if (prompt.schema.length > 0) {
|
|
56
|
+
return ` (${prompt.schema.map((v) => v.name).join(", ")})`;
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
})();
|
|
55
60
|
logger.step(`${prompt.name}${varList}`);
|
|
56
61
|
}
|
|
57
62
|
}
|
|
@@ -77,12 +82,12 @@ export function handleGenerate(
|
|
|
77
82
|
for (const prompt of prompts) {
|
|
78
83
|
const content = generatePromptModule(prompt);
|
|
79
84
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing generated module to output directory
|
|
80
|
-
writeFileSync(resolve(outDir, `${prompt.name}.ts`), content, "
|
|
85
|
+
writeFileSync(resolve(outDir, `${prompt.name}.ts`), content, "utf8");
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
const registryContent = generateRegistry(prompts);
|
|
84
89
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing generated registry to output directory
|
|
85
|
-
writeFileSync(resolve(outDir, "index.ts"), registryContent, "
|
|
90
|
+
writeFileSync(resolve(outDir, "index.ts"), registryContent, "utf8");
|
|
86
91
|
|
|
87
92
|
if (!silent) {
|
|
88
93
|
logger.success(`Generated ${prompts.length} prompt module(s) + registry → ${outDir}`);
|
|
@@ -50,13 +50,14 @@ export function handleLint(
|
|
|
50
50
|
const warnCount = diagnostics.filter((d) => d.level !== "error").length;
|
|
51
51
|
|
|
52
52
|
if (!silent) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
53
|
+
const summaryParts: string[] = [`${discovered} prompt(s) linted`];
|
|
54
|
+
if (errorCount > 0) {
|
|
55
|
+
summaryParts.push(`${errorCount} error(s)`);
|
|
56
|
+
}
|
|
57
|
+
if (warnCount > 0) {
|
|
58
|
+
summaryParts.push(`${warnCount} warning(s)`);
|
|
59
|
+
}
|
|
60
|
+
const summary = summaryParts.join(", ");
|
|
60
61
|
|
|
61
62
|
logger.info(summary);
|
|
62
63
|
}
|
|
@@ -38,7 +38,7 @@ export default command({
|
|
|
38
38
|
"liquid.engine": "standard",
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
writeFileSync(settingsPath, JSON.stringify(updatedSettings, null, 2)
|
|
41
|
+
writeFileSync(settingsPath, `${JSON.stringify(updatedSettings, null, 2)}\n`, "utf8");
|
|
42
42
|
ctx.logger.success(`Updated ${settingsPath}`);
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -57,14 +57,19 @@ export default command({
|
|
|
57
57
|
const currentRecs = (extensions.recommendations ?? []) as string[];
|
|
58
58
|
const extensionId = "sissel.shopify-liquid";
|
|
59
59
|
|
|
60
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
61
|
+
const recommendations: string[] = (() => {
|
|
62
|
+
if (currentRecs.includes(extensionId)) {
|
|
63
|
+
return currentRecs;
|
|
64
|
+
}
|
|
65
|
+
return [...currentRecs, extensionId];
|
|
66
|
+
})();
|
|
60
67
|
const updatedExtensions = {
|
|
61
68
|
...extensions,
|
|
62
|
-
recommendations
|
|
63
|
-
? currentRecs
|
|
64
|
-
: [...currentRecs, extensionId],
|
|
69
|
+
recommendations,
|
|
65
70
|
};
|
|
66
71
|
|
|
67
|
-
writeFileSync(extensionsPath, JSON.stringify(updatedExtensions, null, 2)
|
|
72
|
+
writeFileSync(extensionsPath, `${JSON.stringify(updatedExtensions, null, 2)}\n`, "utf8");
|
|
68
73
|
ctx.logger.success(`Updated ${extensionsPath}`);
|
|
69
74
|
}
|
|
70
75
|
|
|
@@ -75,15 +80,27 @@ export default command({
|
|
|
75
80
|
|
|
76
81
|
if (shouldGitignore) {
|
|
77
82
|
const gitignorePath = resolve(GITIGNORE_FILE);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
84
|
+
const existing: string = (() => {
|
|
85
|
+
if (existsSync(gitignorePath)) {
|
|
86
|
+
return readFileSync(gitignorePath, "utf8");
|
|
87
|
+
}
|
|
88
|
+
return "";
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
if (existing.includes(GITIGNORE_ENTRY)) {
|
|
92
|
+
ctx.logger.info(`${GITIGNORE_ENTRY} already in ${gitignorePath}`);
|
|
93
|
+
} else {
|
|
94
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
95
|
+
const separator: string = (() => {
|
|
96
|
+
if (existing.length > 0 && !existing.endsWith("\n")) {
|
|
97
|
+
return "\n";
|
|
98
|
+
}
|
|
99
|
+
return "";
|
|
100
|
+
})();
|
|
82
101
|
const block = `${separator}\n# Generated prompt client (created by \`funkai prompts generate\`)\n${GITIGNORE_ENTRY}\n`;
|
|
83
|
-
writeFileSync(gitignorePath, existing
|
|
102
|
+
writeFileSync(gitignorePath, `${existing}${block}`, "utf8");
|
|
84
103
|
ctx.logger.success(`Added ${GITIGNORE_ENTRY} to ${gitignorePath}`);
|
|
85
|
-
} else {
|
|
86
|
-
ctx.logger.info(`${GITIGNORE_ENTRY} already in ${gitignorePath}`);
|
|
87
104
|
}
|
|
88
105
|
}
|
|
89
106
|
|
|
@@ -100,7 +117,9 @@ export default command({
|
|
|
100
117
|
const existingPaths = (compilerOptions.paths ?? {}) as Record<string, string[]>;
|
|
101
118
|
|
|
102
119
|
// oxlint-disable-next-line security/detect-object-injection -- safe: PROMPTS_ALIAS is a known constant string
|
|
103
|
-
if (
|
|
120
|
+
if (existingPaths[PROMPTS_ALIAS]) {
|
|
121
|
+
ctx.logger.info(`${PROMPTS_ALIAS} alias already in ${tsconfigPath}`);
|
|
122
|
+
} else {
|
|
104
123
|
const updatedTsconfig = {
|
|
105
124
|
...tsconfig,
|
|
106
125
|
compilerOptions: {
|
|
@@ -113,10 +132,8 @@ export default command({
|
|
|
113
132
|
},
|
|
114
133
|
};
|
|
115
134
|
|
|
116
|
-
writeFileSync(tsconfigPath, JSON.stringify(updatedTsconfig, null, 2)
|
|
135
|
+
writeFileSync(tsconfigPath, `${JSON.stringify(updatedTsconfig, null, 2)}\n`, "utf8");
|
|
117
136
|
ctx.logger.success(`Added ${PROMPTS_ALIAS} alias to ${tsconfigPath}`);
|
|
118
|
-
} else {
|
|
119
|
-
ctx.logger.info(`${PROMPTS_ALIAS} alias already in ${tsconfigPath}`);
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
139
|
|
|
@@ -136,7 +153,7 @@ function readJsonFile(filePath: string): Record<string, unknown> {
|
|
|
136
153
|
|
|
137
154
|
try {
|
|
138
155
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading tsconfig file
|
|
139
|
-
const content = readFileSync(filePath, "
|
|
156
|
+
const content = readFileSync(filePath, "utf8");
|
|
140
157
|
return JSON.parse(content) as Record<string, unknown>;
|
|
141
158
|
} catch {
|
|
142
159
|
return {};
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { extractVariables } from "@/lib/prompts/extract-variables.js";
|
|
4
4
|
|
|
5
|
-
describe(
|
|
5
|
+
describe(extractVariables, () => {
|
|
6
6
|
it("extracts simple variables", () => {
|
|
7
7
|
expect(extractVariables("{{ name }}")).toEqual(["name"]);
|
|
8
8
|
});
|
|
@@ -6,7 +6,7 @@ import { flattenPartials } from "@/lib/prompts/flatten.js";
|
|
|
6
6
|
|
|
7
7
|
const PARTIALS_DIR = resolve(import.meta.dirname, "../../../../../prompts/src/prompts");
|
|
8
8
|
|
|
9
|
-
describe(
|
|
9
|
+
describe(flattenPartials, () => {
|
|
10
10
|
describe("param parsing", () => {
|
|
11
11
|
it("resolves a single literal param", () => {
|
|
12
12
|
const template = "{% render 'identity', role: 'Bot' %}";
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
|
|
4
4
|
|
|
5
|
-
describe(
|
|
5
|
+
describe(parseFrontmatter, () => {
|
|
6
6
|
it("parses name from frontmatter", () => {
|
|
7
7
|
const content = "---\nname: my-prompt\n---\nHello";
|
|
8
8
|
const result = parseFrontmatter(content, "test.prompt");
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
|
|
4
4
|
|
|
5
|
-
describe(
|
|
5
|
+
describe(lintPrompt, () => {
|
|
6
6
|
it("returns no diagnostics when vars match schema", () => {
|
|
7
7
|
const result = lintPrompt(
|
|
8
8
|
"test",
|
|
@@ -50,10 +50,10 @@ describe("lintPrompt", () => {
|
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
describe(
|
|
53
|
+
describe(hasLintErrors, () => {
|
|
54
54
|
it("returns false when no errors", () => {
|
|
55
55
|
const results = [{ name: "test", filePath: "test.prompt", diagnostics: [] }];
|
|
56
|
-
expect(hasLintErrors(results)).
|
|
56
|
+
expect(hasLintErrors(results)).toBeFalsy();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it("returns true when errors exist", () => {
|
|
@@ -64,7 +64,7 @@ describe("hasLintErrors", () => {
|
|
|
64
64
|
diagnostics: [{ level: "error" as const, message: "oops" }],
|
|
65
65
|
},
|
|
66
66
|
];
|
|
67
|
-
expect(hasLintErrors(results)).
|
|
67
|
+
expect(hasLintErrors(results)).toBeTruthy();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
it("returns false when only warnings", () => {
|
|
@@ -75,6 +75,6 @@ describe("hasLintErrors", () => {
|
|
|
75
75
|
diagnostics: [{ level: "warn" as const, message: "hmm" }],
|
|
76
76
|
},
|
|
77
77
|
];
|
|
78
|
-
expect(hasLintErrors(results)).
|
|
78
|
+
expect(hasLintErrors(results)).toBeFalsy();
|
|
79
79
|
});
|
|
80
80
|
});
|
|
@@ -24,7 +24,7 @@ export interface ParsedPrompt {
|
|
|
24
24
|
function toPascalCase(name: string): string {
|
|
25
25
|
return name
|
|
26
26
|
.split("-")
|
|
27
|
-
.map((part) => part.charAt(0).toUpperCase()
|
|
27
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
28
28
|
.join("");
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -38,7 +38,7 @@ function toPascalCase(name: string): string {
|
|
|
38
38
|
*/
|
|
39
39
|
function toCamelCase(name: string): string {
|
|
40
40
|
const pascal = toPascalCase(name);
|
|
41
|
-
return pascal.charAt(0).toLowerCase()
|
|
41
|
+
return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -49,7 +49,10 @@ function toCamelCase(name: string): string {
|
|
|
49
49
|
* @private
|
|
50
50
|
*/
|
|
51
51
|
function escapeTemplateLiteral(str: string): string {
|
|
52
|
-
return str
|
|
52
|
+
return str
|
|
53
|
+
.replaceAll("\\", String.raw`\\`)
|
|
54
|
+
.replaceAll("`", "\\`")
|
|
55
|
+
.replaceAll("${", "\\${");
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/**
|
|
@@ -65,7 +68,13 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
|
|
|
65
68
|
const fields = vars
|
|
66
69
|
.map((v) => {
|
|
67
70
|
const base = "z.string()";
|
|
68
|
-
|
|
71
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
72
|
+
const expr: string = (() => {
|
|
73
|
+
if (v.required) {
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
return `${base}.optional()`;
|
|
77
|
+
})();
|
|
69
78
|
return ` ${v.name}: ${expr},`;
|
|
70
79
|
})
|
|
71
80
|
.join("\n");
|
|
@@ -93,7 +102,13 @@ const HEADER = [
|
|
|
93
102
|
export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
94
103
|
const escaped = escapeTemplateLiteral(prompt.template);
|
|
95
104
|
const schemaExpr = generateSchemaExpression(prompt.schema);
|
|
96
|
-
|
|
105
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
106
|
+
const groupValue: string = (() => {
|
|
107
|
+
if (prompt.group) {
|
|
108
|
+
return `'${prompt.group}' as const`;
|
|
109
|
+
}
|
|
110
|
+
return "undefined";
|
|
111
|
+
})();
|
|
97
112
|
|
|
98
113
|
const lines: string[] = [
|
|
99
114
|
HEADER,
|
|
@@ -140,9 +155,9 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
|
140
155
|
* A tree node used during registry code generation.
|
|
141
156
|
* Leaves hold the camelCase import name; branches hold nested nodes.
|
|
142
157
|
*/
|
|
143
|
-
|
|
158
|
+
interface TreeNode {
|
|
144
159
|
readonly [key: string]: string | TreeNode;
|
|
145
|
-
}
|
|
160
|
+
}
|
|
146
161
|
|
|
147
162
|
/**
|
|
148
163
|
* Build a nested tree from sorted prompts, grouped by their `group` field.
|
|
@@ -156,17 +171,22 @@ type TreeNode = {
|
|
|
156
171
|
function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
157
172
|
return prompts.reduce<Record<string, unknown>>((root, prompt) => {
|
|
158
173
|
const importName = toCamelCase(prompt.name);
|
|
159
|
-
|
|
174
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
175
|
+
const segments: string[] = (() => {
|
|
176
|
+
if (prompt.group) {
|
|
177
|
+
return prompt.group.split("/").map(toCamelCase);
|
|
178
|
+
}
|
|
179
|
+
return [];
|
|
180
|
+
})();
|
|
160
181
|
|
|
161
182
|
const target = segments.reduce<Record<string, unknown>>((current, segment) => {
|
|
162
183
|
const existing = current[segment];
|
|
163
184
|
if (typeof existing === "string") {
|
|
164
|
-
throw new
|
|
165
|
-
`Collision: prompt "${existing}" and group namespace "${segment}"
|
|
166
|
-
"share the same key at the same level.",
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
`Collision: prompt "${existing}" and group namespace "${segment}" share the same key at the same level.`,
|
|
167
187
|
);
|
|
168
188
|
}
|
|
169
|
-
if (existing
|
|
189
|
+
if (existing === null || existing === undefined) {
|
|
170
190
|
current[segment] = {};
|
|
171
191
|
}
|
|
172
192
|
return current[segment] as Record<string, unknown>;
|
|
@@ -174,8 +194,7 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
|
174
194
|
|
|
175
195
|
if (typeof target[importName] === "object" && target[importName] !== null) {
|
|
176
196
|
throw new Error(
|
|
177
|
-
`Collision: prompt "${importName}" conflicts with existing group namespace
|
|
178
|
-
`"${importName}" at the same level.`,
|
|
197
|
+
`Collision: prompt "${importName}" conflicts with existing group namespace "${importName}" at the same level.`,
|
|
179
198
|
);
|
|
180
199
|
}
|
|
181
200
|
|
|
@@ -196,11 +215,19 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
|
196
215
|
function serializeTree(node: TreeNode, indent: number): string[] {
|
|
197
216
|
const pad = " ".repeat(indent);
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
for (const [key, value] of Object.entries(node)) {
|
|
220
|
+
if (typeof value === "string") {
|
|
221
|
+
lines.push(`${pad}${key},`);
|
|
222
|
+
} else {
|
|
223
|
+
lines.push(`${pad}${key}: {`);
|
|
224
|
+
for (const child of serializeTree(value, indent + 1)) {
|
|
225
|
+
lines.push(child);
|
|
226
|
+
}
|
|
227
|
+
lines.push(`${pad}},`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return lines;
|
|
204
231
|
}
|
|
205
232
|
|
|
206
233
|
/**
|
|
@@ -18,7 +18,13 @@ export function extractVariables(template: string): string[] {
|
|
|
18
18
|
|
|
19
19
|
const roots = new Set(
|
|
20
20
|
variables.map((variable) => {
|
|
21
|
-
|
|
21
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
22
|
+
const root: string = (() => {
|
|
23
|
+
if (Array.isArray(variable)) {
|
|
24
|
+
return String(variable[0]);
|
|
25
|
+
}
|
|
26
|
+
return String(variable);
|
|
27
|
+
})();
|
|
22
28
|
|
|
23
29
|
if (DANGEROUS_NAMES.has(root)) {
|
|
24
30
|
throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
|
|
@@ -39,8 +39,13 @@ function parseParams(raw: string, partialName: string): Record<string, string> {
|
|
|
39
39
|
*/
|
|
40
40
|
function parseRenderTags(template: string): RenderTag[] {
|
|
41
41
|
return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
|
|
42
|
-
const rawParams = m[2]
|
|
43
|
-
const params
|
|
42
|
+
const rawParams: string = (m[2] ?? "").trim();
|
|
43
|
+
const params: Record<string, string> = (() => {
|
|
44
|
+
if (rawParams.length > 0) {
|
|
45
|
+
return parseParams(rawParams, m[1]);
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
})();
|
|
44
49
|
|
|
45
50
|
return { fullMatch: m[0], partialName: m[1], params };
|
|
46
51
|
});
|
|
@@ -60,15 +65,15 @@ function parseRenderTags(template: string): RenderTag[] {
|
|
|
60
65
|
* @param partialsDirs - Directories to search for partial `.prompt` files.
|
|
61
66
|
* @returns Flattened template with all render tags resolved.
|
|
62
67
|
*/
|
|
63
|
-
export function flattenPartials(template: string, partialsDirs: string[]): string {
|
|
68
|
+
export function flattenPartials(template: string, partialsDirs: readonly string[]): string {
|
|
64
69
|
const tags = parseRenderTags(template);
|
|
65
70
|
if (tags.length === 0) {
|
|
66
71
|
return template;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
const engine = new Liquid({
|
|
70
|
-
root: partialsDirs,
|
|
71
|
-
partials: partialsDirs,
|
|
75
|
+
root: [...partialsDirs],
|
|
76
|
+
partials: [...partialsDirs],
|
|
72
77
|
extname: ".prompt",
|
|
73
78
|
});
|
|
74
79
|
|
|
@@ -16,8 +16,8 @@ export const NAME_RE = /^[a-z0-9-]+$/;
|
|
|
16
16
|
function parseYamlContent(yaml: string, filePath: string): Record<string, unknown> {
|
|
17
17
|
try {
|
|
18
18
|
return parseYaml(yaml) as Record<string, unknown>;
|
|
19
|
-
} catch (
|
|
20
|
-
throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${error}`, { cause: error });
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -64,7 +64,7 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
64
64
|
throw new Error(`Frontmatter is not a valid object in ${filePath}`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const name = parsed
|
|
67
|
+
const { name } = parsed;
|
|
68
68
|
if (typeof name !== "string" || name.length === 0) {
|
|
69
69
|
throw new Error(`Missing or empty "name" in frontmatter: ${filePath}`);
|
|
70
70
|
}
|
|
@@ -76,21 +76,25 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const group =
|
|
80
|
-
typeof parsed.group === "string"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
79
|
+
const group: string | undefined = (() => {
|
|
80
|
+
if (typeof parsed.group === "string") {
|
|
81
|
+
const g = parsed.group as string;
|
|
82
|
+
const invalidSegment = g.split("/").find((segment) => !NAME_RE.test(segment));
|
|
83
|
+
if (invalidSegment !== undefined) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return g;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
})();
|
|
92
|
+
const version: string | undefined = (() => {
|
|
93
|
+
if (parsed.version !== null && parsed.version !== undefined) {
|
|
94
|
+
return String(parsed.version);
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
})();
|
|
94
98
|
|
|
95
99
|
const schema = parseSchemaBlock(parsed.schema, filePath);
|
|
96
100
|
|
|
@@ -103,12 +107,12 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
103
107
|
* @private
|
|
104
108
|
*/
|
|
105
109
|
function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
|
|
106
|
-
if (raw
|
|
110
|
+
if (raw === null || raw === undefined) {
|
|
107
111
|
return [];
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
111
|
-
throw new
|
|
115
|
+
throw new TypeError(
|
|
112
116
|
`Invalid "schema" in ${filePath}: expected an object mapping variable names to definitions`,
|
|
113
117
|
);
|
|
114
118
|
}
|
|
@@ -122,10 +126,20 @@ function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
|
|
|
122
126
|
|
|
123
127
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
124
128
|
const def = value as Record<string, unknown>;
|
|
125
|
-
|
|
129
|
+
// oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
|
|
130
|
+
const type: string = (() => {
|
|
131
|
+
if (typeof def.type === "string") {
|
|
132
|
+
return def.type as string;
|
|
133
|
+
}
|
|
134
|
+
return "string";
|
|
135
|
+
})();
|
|
126
136
|
const required = def.required !== false;
|
|
127
|
-
const description =
|
|
128
|
-
typeof def.description === "string"
|
|
137
|
+
const description: string | undefined = (() => {
|
|
138
|
+
if (typeof def.description === "string") {
|
|
139
|
+
return def.description as string;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
})();
|
|
129
143
|
|
|
130
144
|
return { name: varName, type, required, description };
|
|
131
145
|
}
|
package/src/lib/prompts/paths.ts
CHANGED
|
@@ -23,7 +23,7 @@ function extractName(content: string): string | undefined {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const frontmatter = match
|
|
26
|
+
const [, frontmatter] = match;
|
|
27
27
|
const nameLine = frontmatter.split("\n").find((line) => line.startsWith("name:"));
|
|
28
28
|
if (!nameLine) {
|
|
29
29
|
return undefined;
|
|
@@ -74,7 +74,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
74
74
|
|
|
75
75
|
if (entry.isFile() && extname(entry.name) === PROMPT_EXT) {
|
|
76
76
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading prompt file content for name extraction
|
|
77
|
-
const content = readFileSync(fullPath, "
|
|
77
|
+
const content = readFileSync(fullPath, "utf8");
|
|
78
78
|
const name = extractName(content) ?? deriveNameFromPath(fullPath);
|
|
79
79
|
|
|
80
80
|
if (!NAME_RE.test(name)) {
|
|
@@ -3,13 +3,28 @@ import { resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { clean, PARTIALS_DIR } from "@funkai/prompts/cli";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { ParsedPrompt } from "./codegen.js";
|
|
7
7
|
import { extractVariables } from "./extract-variables.js";
|
|
8
8
|
import { flattenPartials } from "./flatten.js";
|
|
9
9
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
10
|
-
import { lintPrompt
|
|
10
|
+
import { lintPrompt } from "./lint.js";
|
|
11
|
+
import type { LintResult } from "./lint.js";
|
|
11
12
|
import { discoverPrompts } from "./paths.js";
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the list of partial directories to search.
|
|
16
|
+
*
|
|
17
|
+
* @param customDir - Custom partials directory path.
|
|
18
|
+
* @returns Array of directories to search for partials.
|
|
19
|
+
*/
|
|
20
|
+
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
|
|
21
|
+
function resolvePartialsDirs(customDir: string): readonly string[] {
|
|
22
|
+
if (existsSync(customDir)) {
|
|
23
|
+
return [customDir, PARTIALS_DIR];
|
|
24
|
+
}
|
|
25
|
+
return [PARTIALS_DIR];
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Options for the prompts lint pipeline.
|
|
15
30
|
*/
|
|
@@ -35,14 +50,11 @@ export interface LintPipelineResult {
|
|
|
35
50
|
export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
|
|
36
51
|
const discovered = discoverPrompts([...options.roots]);
|
|
37
52
|
const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
|
|
38
|
-
|
|
39
|
-
const partialsDirs = existsSync(customPartialsDir)
|
|
40
|
-
? [customPartialsDir, PARTIALS_DIR]
|
|
41
|
-
: [PARTIALS_DIR];
|
|
53
|
+
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
42
54
|
|
|
43
55
|
const results = discovered.map((d) => {
|
|
44
56
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
|
|
45
|
-
const raw = readFileSync(d.filePath, "
|
|
57
|
+
const raw = readFileSync(d.filePath, "utf8");
|
|
46
58
|
const frontmatter = parseFrontmatter(raw, d.filePath);
|
|
47
59
|
const template = flattenPartials(clean(raw), partialsDirs);
|
|
48
60
|
const templateVars = extractVariables(template);
|
|
@@ -81,14 +93,11 @@ export interface GeneratePipelineResult {
|
|
|
81
93
|
export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
|
|
82
94
|
const discovered = discoverPrompts([...options.roots]);
|
|
83
95
|
const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
|
|
84
|
-
|
|
85
|
-
const partialsDirs = existsSync(customPartialsDir)
|
|
86
|
-
? [customPartialsDir, PARTIALS_DIR]
|
|
87
|
-
: [PARTIALS_DIR];
|
|
96
|
+
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
88
97
|
|
|
89
98
|
const processed = discovered.map((d) => {
|
|
90
99
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
|
|
91
|
-
const raw = readFileSync(d.filePath, "
|
|
100
|
+
const raw = readFileSync(d.filePath, "utf8");
|
|
92
101
|
const frontmatter = parseFrontmatter(raw, d.filePath);
|
|
93
102
|
const template = flattenPartials(clean(raw), partialsDirs);
|
|
94
103
|
const templateVars = extractVariables(template);
|