@funkai/cli 0.1.3 → 0.2.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 +55 -0
- package/dist/index.mjs +7591 -9666
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -5
- package/src/commands/generate.ts +1 -1
- package/src/commands/prompts/create.ts +3 -2
- package/src/commands/prompts/generate.ts +39 -25
- package/src/commands/prompts/lint.ts +34 -24
- package/src/commands/prompts/setup.ts +64 -21
- package/src/commands/validate.ts +1 -1
- package/src/lib/prompts/__tests__/extract-variables.test.ts +1 -1
- package/src/lib/prompts/__tests__/flatten.test.ts +30 -28
- package/src/lib/prompts/__tests__/frontmatter.test.ts +18 -16
- package/src/lib/prompts/__tests__/lint.test.ts +5 -5
- package/src/lib/prompts/codegen.ts +47 -20
- package/src/lib/prompts/extract-variables.ts +20 -3
- package/src/lib/prompts/flatten.ts +65 -14
- package/src/lib/prompts/frontmatter.ts +102 -44
- package/src/lib/prompts/lint.ts +18 -23
- package/src/lib/prompts/paths.ts +25 -11
- package/src/lib/prompts/pipeline.ts +26 -16
|
@@ -10,11 +10,25 @@ interface RenderTag {
|
|
|
10
10
|
params: Record<string, string>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Private
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** @private */
|
|
18
|
+
function parseParamsOrEmpty(raw: string, partialName: string): Record<string, string> {
|
|
19
|
+
if (raw.length > 0) {
|
|
20
|
+
return parseParams(raw, partialName);
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Parse literal string parameters from a render tag's param string.
|
|
15
27
|
*
|
|
16
28
|
* Only supports literal string values (e.g. `role: 'Bot'`).
|
|
17
29
|
* Throws if a parameter value is a variable reference.
|
|
30
|
+
*
|
|
31
|
+
* @private
|
|
18
32
|
*/
|
|
19
33
|
function parseParams(raw: string, partialName: string): Record<string, string> {
|
|
20
34
|
const literalMatches = [...raw.matchAll(LITERAL_PARAM_RE)];
|
|
@@ -34,18 +48,56 @@ function parseParams(raw: string, partialName: string): Record<string, string> {
|
|
|
34
48
|
);
|
|
35
49
|
}
|
|
36
50
|
|
|
51
|
+
/** @private */
|
|
52
|
+
function errorMessage(error: unknown): string {
|
|
53
|
+
if (error instanceof Error) {
|
|
54
|
+
return error.message;
|
|
55
|
+
}
|
|
56
|
+
return String(error);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render a single partial tag via LiquidJS, wrapping errors with context.
|
|
61
|
+
*
|
|
62
|
+
* @private
|
|
63
|
+
*/
|
|
64
|
+
function renderPartial(engine: Liquid, tag: RenderTag): string {
|
|
65
|
+
const liquidTag = `{% render '${tag.partialName}' ${Object.entries(tag.params)
|
|
66
|
+
.map(([k, v]) => `${k}: '${v}'`)
|
|
67
|
+
.join(", ")} %}`;
|
|
68
|
+
try {
|
|
69
|
+
return engine.parseAndRenderSync(liquidTag);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new Error(`Failed to render partial '${tag.partialName}': ${errorMessage(error)}`, {
|
|
72
|
+
cause: error,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* Find all `{% render %}` tags in a template string.
|
|
79
|
+
*
|
|
80
|
+
* @private
|
|
39
81
|
*/
|
|
40
82
|
function parseRenderTags(template: string): RenderTag[] {
|
|
41
83
|
return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
|
|
42
|
-
const rawParams = m[2]
|
|
43
|
-
const params =
|
|
84
|
+
const rawParams: string = (m[2] ?? "").trim();
|
|
85
|
+
const params = parseParamsOrEmpty(rawParams, m[1]);
|
|
44
86
|
|
|
45
87
|
return { fullMatch: m[0], partialName: m[1], params };
|
|
46
88
|
});
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Parameters for flattening partial render tags.
|
|
93
|
+
*/
|
|
94
|
+
export interface FlattenPartialsParams {
|
|
95
|
+
/** Template string (frontmatter already stripped). */
|
|
96
|
+
readonly template: string;
|
|
97
|
+
/** Directories to search for partial `.prompt` files. */
|
|
98
|
+
readonly partialsDirs: readonly string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
49
101
|
/**
|
|
50
102
|
* Flatten `{% render %}` partial tags in a template at codegen time.
|
|
51
103
|
*
|
|
@@ -56,30 +108,29 @@ function parseRenderTags(template: string): RenderTag[] {
|
|
|
56
108
|
* All other Liquid expressions (`{{ var }}`, `{% if %}`, `{% for %}`)
|
|
57
109
|
* are preserved for runtime rendering.
|
|
58
110
|
*
|
|
59
|
-
* @param
|
|
60
|
-
* @param partialsDirs - Directories to search for partial `.prompt` files.
|
|
111
|
+
* @param params - Template content and partial directories.
|
|
61
112
|
* @returns Flattened template with all render tags resolved.
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* flattenPartials({ template: "{% render 'header' %}\nBody", partialsDirs: ["./partials"] });
|
|
116
|
+
* // "Welcome!\nBody"
|
|
117
|
+
* ```
|
|
62
118
|
*/
|
|
63
|
-
export function flattenPartials(template
|
|
119
|
+
export function flattenPartials({ template, partialsDirs }: FlattenPartialsParams): string {
|
|
64
120
|
const tags = parseRenderTags(template);
|
|
65
121
|
if (tags.length === 0) {
|
|
66
122
|
return template;
|
|
67
123
|
}
|
|
68
124
|
|
|
69
125
|
const engine = new Liquid({
|
|
70
|
-
root: partialsDirs,
|
|
71
|
-
partials: partialsDirs,
|
|
126
|
+
root: [...partialsDirs],
|
|
127
|
+
partials: [...partialsDirs],
|
|
72
128
|
extname: ".prompt",
|
|
73
129
|
});
|
|
74
130
|
|
|
75
131
|
const result = tags.reduce((acc, tag) => {
|
|
76
|
-
const rendered = engine
|
|
77
|
-
|
|
78
|
-
.map(([k, v]) => `${k}: '${v}'`)
|
|
79
|
-
.join(", ")} %}`,
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
return acc.replace(tag.fullMatch, rendered);
|
|
132
|
+
const rendered = renderPartial(engine, tag);
|
|
133
|
+
return acc.replace(tag.fullMatch, () => rendered);
|
|
83
134
|
}, template);
|
|
84
135
|
|
|
85
136
|
return result;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { match, P } from "ts-pattern";
|
|
1
2
|
import { parse as parseYaml } from "yaml";
|
|
2
3
|
|
|
4
|
+
/** Regex matching YAML frontmatter fenced by `---` delimiters. */
|
|
3
5
|
export const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
6
|
+
|
|
7
|
+
/** Regex validating prompt names (lowercase alphanumeric with hyphens). */
|
|
4
8
|
export const NAME_RE = /^[a-z0-9-]+$/;
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -16,8 +20,8 @@ export const NAME_RE = /^[a-z0-9-]+$/;
|
|
|
16
20
|
function parseYamlContent(yaml: string, filePath: string): Record<string, unknown> {
|
|
17
21
|
try {
|
|
18
22
|
return parseYaml(yaml) as Record<string, unknown>;
|
|
19
|
-
} catch (
|
|
20
|
-
throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${error}`, { cause: error });
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -41,18 +45,32 @@ export interface ParsedFrontmatter {
|
|
|
41
45
|
readonly schema: readonly SchemaVariable[];
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Parameters for parsing frontmatter from a `.prompt` file.
|
|
50
|
+
*/
|
|
51
|
+
export interface ParseFrontmatterParams {
|
|
52
|
+
/** Raw file content (including frontmatter fences). */
|
|
53
|
+
readonly content: string;
|
|
54
|
+
/** File path for error messages. */
|
|
55
|
+
readonly filePath: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/**
|
|
45
59
|
* Parse YAML frontmatter from a `.prompt` file's raw content.
|
|
46
60
|
*
|
|
47
61
|
* Extracts `name`, `group`, `version`, and `schema` fields.
|
|
48
62
|
* The `schema` field maps variable names to their type definitions.
|
|
49
63
|
*
|
|
50
|
-
* @param
|
|
51
|
-
* @param filePath - File path for error messages.
|
|
64
|
+
* @param params - Content and file path to parse.
|
|
52
65
|
* @returns Parsed frontmatter with schema variables.
|
|
53
66
|
* @throws If frontmatter is missing, malformed, or has an invalid name.
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const fm = parseFrontmatter({ content: "---\nname: greeting\n---\nHello!", filePath: "greeting.prompt" });
|
|
70
|
+
* // { name: "greeting", group: undefined, version: undefined, schema: [] }
|
|
71
|
+
* ```
|
|
54
72
|
*/
|
|
55
|
-
export function parseFrontmatter(content
|
|
73
|
+
export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams): ParsedFrontmatter {
|
|
56
74
|
const fmMatch = content.match(FRONTMATTER_RE);
|
|
57
75
|
if (!fmMatch) {
|
|
58
76
|
throw new Error(`No frontmatter found in ${filePath}`);
|
|
@@ -64,7 +82,7 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
64
82
|
throw new Error(`Frontmatter is not a valid object in ${filePath}`);
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
const name = parsed
|
|
85
|
+
const { name } = parsed;
|
|
68
86
|
if (typeof name !== "string" || name.length === 0) {
|
|
69
87
|
throw new Error(`Missing or empty "name" in frontmatter: ${filePath}`);
|
|
70
88
|
}
|
|
@@ -76,63 +94,103 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
76
94
|
);
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
const group =
|
|
80
|
-
|
|
81
|
-
? (() => {
|
|
82
|
-
const g = parsed.group as string;
|
|
83
|
-
const invalidSegment = g.split("/").find((segment) => !NAME_RE.test(segment));
|
|
84
|
-
if (invalidSegment !== undefined) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`Invalid group segment "${invalidSegment}" in ${filePath}. ` +
|
|
87
|
-
"Group segments must be lowercase alphanumeric with hyphens only.",
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
return g;
|
|
91
|
-
})()
|
|
92
|
-
: undefined;
|
|
93
|
-
const version = parsed.version != null ? String(parsed.version) : undefined;
|
|
97
|
+
const group = parseGroup(parsed.group, filePath);
|
|
98
|
+
const version = parseVersion(parsed.version);
|
|
94
99
|
|
|
95
100
|
const schema = parseSchemaBlock(parsed.schema, filePath);
|
|
96
101
|
|
|
97
102
|
return { name, group, version, schema };
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Private
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** @private */
|
|
110
|
+
function stringOrDefault(value: unknown, fallback: string): string {
|
|
111
|
+
if (typeof value === "string") {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
return fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @private */
|
|
118
|
+
function stringOrUndefined(value: unknown): string | undefined {
|
|
119
|
+
if (typeof value === "string") {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate and extract the `group` field from parsed frontmatter.
|
|
127
|
+
*
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
function parseGroup(raw: unknown, filePath: string): string | undefined {
|
|
131
|
+
if (typeof raw !== "string") {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const invalidSegment = raw.split("/").find((segment) => !NAME_RE.test(segment));
|
|
135
|
+
if (invalidSegment !== undefined) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return raw;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract the `version` field from parsed frontmatter.
|
|
145
|
+
*
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
function parseVersion(raw: unknown): string | undefined {
|
|
149
|
+
if (raw !== null && raw !== undefined) {
|
|
150
|
+
return String(raw);
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
100
155
|
/**
|
|
101
156
|
* Parse the `schema` block from frontmatter into an array of variable definitions.
|
|
102
157
|
*
|
|
103
158
|
* @private
|
|
104
159
|
*/
|
|
105
|
-
function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
|
|
106
|
-
if (raw
|
|
160
|
+
function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariable[] {
|
|
161
|
+
if (raw === null || raw === undefined) {
|
|
107
162
|
return [];
|
|
108
163
|
}
|
|
109
164
|
|
|
110
165
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
111
|
-
throw new
|
|
166
|
+
throw new TypeError(
|
|
112
167
|
`Invalid "schema" in ${filePath}: expected an object mapping variable names to definitions`,
|
|
113
168
|
);
|
|
114
169
|
}
|
|
115
170
|
|
|
116
171
|
const schema = raw as Record<string, unknown>;
|
|
117
172
|
|
|
118
|
-
return Object.entries(schema).map(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
return Object.entries(schema).map(
|
|
174
|
+
([varName, value]): SchemaVariable =>
|
|
175
|
+
match(value)
|
|
176
|
+
.with(P.string, (v) => ({ name: varName, type: v, required: true }))
|
|
177
|
+
.with(
|
|
178
|
+
P.when(
|
|
179
|
+
(v): v is Record<string, unknown> =>
|
|
180
|
+
typeof v === "object" && v !== null && !Array.isArray(v),
|
|
181
|
+
),
|
|
182
|
+
(def) => {
|
|
183
|
+
const type = stringOrDefault(def.type, "string");
|
|
184
|
+
const required = def.required !== false;
|
|
185
|
+
const description = stringOrUndefined(def.description);
|
|
186
|
+
return { name: varName, type, required, description };
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
.otherwise(() => {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Invalid schema definition for "${varName}" in ${filePath}. ` +
|
|
192
|
+
"Expected a type string or an object with { type, required?, description? }.",
|
|
193
|
+
);
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
138
196
|
}
|
package/src/lib/prompts/lint.ts
CHANGED
|
@@ -36,34 +36,29 @@ export function lintPrompt(
|
|
|
36
36
|
schemaVars: readonly SchemaVariable[],
|
|
37
37
|
templateVars: readonly string[],
|
|
38
38
|
): LintResult {
|
|
39
|
-
const diagnostics: LintDiagnostic[] = [];
|
|
40
39
|
const declared = new Set(schemaVars.map((v) => v.name));
|
|
41
40
|
const used = new Set(templateVars);
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
42
|
+
const undeclaredErrors: readonly LintDiagnostic[] = [...used]
|
|
43
|
+
.filter((varName) => !declared.has(varName))
|
|
44
|
+
.map((varName) => ({
|
|
45
|
+
level: "error" as const,
|
|
46
|
+
message:
|
|
47
|
+
`Undefined variable "${varName}" in ${name}.prompt\n` +
|
|
48
|
+
` Variable "${varName}" is used in the template but not declared in frontmatter schema.\n` +
|
|
49
|
+
" Add it to the schema section in the frontmatter.",
|
|
50
|
+
}));
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
}
|
|
52
|
+
const unusedWarnings: readonly LintDiagnostic[] = [...declared]
|
|
53
|
+
.filter((varName) => !used.has(varName))
|
|
54
|
+
.map((varName) => ({
|
|
55
|
+
level: "warn" as const,
|
|
56
|
+
message:
|
|
57
|
+
`Unused variable "${varName}" in ${name}.prompt\n` +
|
|
58
|
+
` Variable "${varName}" is declared in the schema but never used in the template.`,
|
|
59
|
+
}));
|
|
65
60
|
|
|
66
|
-
return { name, filePath, diagnostics };
|
|
61
|
+
return { name, filePath, diagnostics: [...undeclaredErrors, ...unusedWarnings] };
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
/**
|
package/src/lib/prompts/paths.ts
CHANGED
|
@@ -1,35 +1,45 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, extname, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
4
6
|
import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
|
|
5
7
|
|
|
6
8
|
const MAX_DEPTH = 5;
|
|
7
9
|
const PROMPT_EXT = ".prompt";
|
|
8
10
|
|
|
11
|
+
/** A `.prompt` file discovered during directory scanning. */
|
|
9
12
|
export interface DiscoveredPrompt {
|
|
10
13
|
readonly name: string;
|
|
11
14
|
readonly filePath: string;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Private
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
14
21
|
/**
|
|
15
22
|
* Extract the `name` field from YAML frontmatter.
|
|
16
23
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
24
|
+
* Uses a proper YAML parser to handle quoted values and edge cases.
|
|
25
|
+
*
|
|
26
|
+
* @private
|
|
19
27
|
*/
|
|
20
28
|
function extractName(content: string): string | undefined {
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
29
|
+
const fmMatch = content.match(FRONTMATTER_RE);
|
|
30
|
+
if (!fmMatch) {
|
|
23
31
|
return undefined;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
try {
|
|
35
|
+
const parsed = parseYaml(fmMatch[1]) as Record<string, unknown> | null;
|
|
36
|
+
if (parsed !== null && parsed !== undefined && typeof parsed.name === "string") {
|
|
37
|
+
return parsed.name;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
} catch {
|
|
29
41
|
return undefined;
|
|
30
42
|
}
|
|
31
|
-
|
|
32
|
-
return nameLine.slice("name:".length).trim();
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -37,6 +47,8 @@ function extractName(content: string): string | undefined {
|
|
|
37
47
|
*
|
|
38
48
|
* If the file is named `prompt.prompt`, uses the parent directory name.
|
|
39
49
|
* Otherwise uses the file stem (e.g. `my-agent.prompt` -> `my-agent`).
|
|
50
|
+
*
|
|
51
|
+
* @private
|
|
40
52
|
*/
|
|
41
53
|
function deriveNameFromPath(filePath: string): string {
|
|
42
54
|
const stem = basename(filePath, PROMPT_EXT);
|
|
@@ -48,6 +60,8 @@ function deriveNameFromPath(filePath: string): string {
|
|
|
48
60
|
|
|
49
61
|
/**
|
|
50
62
|
* Recursively scan a directory for `.prompt` files.
|
|
63
|
+
*
|
|
64
|
+
* @private
|
|
51
65
|
*/
|
|
52
66
|
function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
53
67
|
if (depth > MAX_DEPTH) {
|
|
@@ -74,7 +88,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
74
88
|
|
|
75
89
|
if (entry.isFile() && extname(entry.name) === PROMPT_EXT) {
|
|
76
90
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading prompt file content for name extraction
|
|
77
|
-
const content = readFileSync(fullPath, "
|
|
91
|
+
const content = readFileSync(fullPath, "utf8");
|
|
78
92
|
const name = extractName(content) ?? deriveNameFromPath(fullPath);
|
|
79
93
|
|
|
80
94
|
if (!NAME_RE.test(name)) {
|
|
@@ -102,7 +116,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
102
116
|
* @returns Sorted, deduplicated list of discovered prompts.
|
|
103
117
|
* @throws If duplicate prompt names are found across roots.
|
|
104
118
|
*/
|
|
105
|
-
export function discoverPrompts(roots: readonly string[]): DiscoveredPrompt[] {
|
|
119
|
+
export function discoverPrompts(roots: readonly string[]): readonly DiscoveredPrompt[] {
|
|
106
120
|
const all = roots.flatMap((root) => scanDirectory(resolve(root), 0));
|
|
107
121
|
|
|
108
122
|
const byName = Map.groupBy(all, (prompt) => prompt.name);
|
|
@@ -3,13 +3,29 @@ 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
|
+
* @private
|
|
18
|
+
* @param customDir - Custom partials directory path.
|
|
19
|
+
* @returns Array of directories to search for partials.
|
|
20
|
+
*/
|
|
21
|
+
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
|
|
22
|
+
function resolvePartialsDirs(customDir: string): readonly string[] {
|
|
23
|
+
if (existsSync(customDir)) {
|
|
24
|
+
return [customDir, PARTIALS_DIR];
|
|
25
|
+
}
|
|
26
|
+
return [PARTIALS_DIR];
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* Options for the prompts lint pipeline.
|
|
15
31
|
*/
|
|
@@ -35,16 +51,13 @@ export interface LintPipelineResult {
|
|
|
35
51
|
export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
|
|
36
52
|
const discovered = discoverPrompts([...options.roots]);
|
|
37
53
|
const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
|
|
38
|
-
|
|
39
|
-
const partialsDirs = existsSync(customPartialsDir)
|
|
40
|
-
? [customPartialsDir, PARTIALS_DIR]
|
|
41
|
-
: [PARTIALS_DIR];
|
|
54
|
+
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
42
55
|
|
|
43
56
|
const results = discovered.map((d) => {
|
|
44
57
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
|
|
45
|
-
const raw = readFileSync(d.filePath, "
|
|
46
|
-
const frontmatter = parseFrontmatter(raw, d.filePath);
|
|
47
|
-
const template = flattenPartials(clean(raw), partialsDirs);
|
|
58
|
+
const raw = readFileSync(d.filePath, "utf8");
|
|
59
|
+
const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
|
|
60
|
+
const template = flattenPartials({ template: clean(raw), partialsDirs });
|
|
48
61
|
const templateVars = extractVariables(template);
|
|
49
62
|
return lintPrompt(frontmatter.name, d.filePath, frontmatter.schema, templateVars);
|
|
50
63
|
});
|
|
@@ -81,16 +94,13 @@ export interface GeneratePipelineResult {
|
|
|
81
94
|
export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
|
|
82
95
|
const discovered = discoverPrompts([...options.roots]);
|
|
83
96
|
const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
|
|
84
|
-
|
|
85
|
-
const partialsDirs = existsSync(customPartialsDir)
|
|
86
|
-
? [customPartialsDir, PARTIALS_DIR]
|
|
87
|
-
: [PARTIALS_DIR];
|
|
97
|
+
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
88
98
|
|
|
89
99
|
const processed = discovered.map((d) => {
|
|
90
100
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
|
|
91
|
-
const raw = readFileSync(d.filePath, "
|
|
92
|
-
const frontmatter = parseFrontmatter(raw, d.filePath);
|
|
93
|
-
const template = flattenPartials(clean(raw), partialsDirs);
|
|
101
|
+
const raw = readFileSync(d.filePath, "utf8");
|
|
102
|
+
const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
|
|
103
|
+
const template = flattenPartials({ template: clean(raw), partialsDirs });
|
|
94
104
|
const templateVars = extractVariables(template);
|
|
95
105
|
|
|
96
106
|
return {
|