@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
|
@@ -2,6 +2,17 @@ import { Liquid } from "liquidjs";
|
|
|
2
2
|
|
|
3
3
|
const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
|
|
4
4
|
|
|
5
|
+
/** Module-level engine — no config needed for variable extraction. */
|
|
6
|
+
const engine = new Liquid();
|
|
7
|
+
|
|
8
|
+
/** @private */
|
|
9
|
+
function extractRoot(variable: unknown): string {
|
|
10
|
+
if (Array.isArray(variable)) {
|
|
11
|
+
return String(variable[0]);
|
|
12
|
+
}
|
|
13
|
+
return String(variable);
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
/**
|
|
6
17
|
* Extract top-level variable names from a Liquid template string.
|
|
7
18
|
*
|
|
@@ -9,22 +20,22 @@ const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
|
|
|
9
20
|
* and extract all referenced variable names. Only returns the root
|
|
10
21
|
* variable name (e.g. `user` from `{{ user.name }}`).
|
|
11
22
|
*
|
|
23
|
+
* @param template - The Liquid template string to parse.
|
|
24
|
+
* @returns Sorted, deduplicated array of top-level variable names.
|
|
12
25
|
* @throws {Error} If a variable name is dangerous (e.g. `__proto__`)
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* extractVariables("Hello {{ user.name }}, you have {{ count }} items");
|
|
29
|
+
* // ["count", "user"]
|
|
30
|
+
* ```
|
|
13
31
|
*/
|
|
14
|
-
export function extractVariables(template: string): string[] {
|
|
15
|
-
const engine = new Liquid();
|
|
32
|
+
export function extractVariables(template: string): readonly string[] {
|
|
16
33
|
const parsed = engine.parse(template);
|
|
17
34
|
const variables = engine.variablesSync(parsed);
|
|
18
35
|
|
|
19
36
|
const roots = new Set(
|
|
20
37
|
variables.map((variable) => {
|
|
21
|
-
|
|
22
|
-
const root: string = (() => {
|
|
23
|
-
if (Array.isArray(variable)) {
|
|
24
|
-
return String(variable[0]);
|
|
25
|
-
}
|
|
26
|
-
return String(variable);
|
|
27
|
-
})();
|
|
38
|
+
const root = extractRoot(variable);
|
|
28
39
|
|
|
29
40
|
if (DANGEROUS_NAMES.has(root)) {
|
|
30
41
|
throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
|
|
@@ -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,23 +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
84
|
const rawParams: string = (m[2] ?? "").trim();
|
|
43
|
-
const params
|
|
44
|
-
if (rawParams.length > 0) {
|
|
45
|
-
return parseParams(rawParams, m[1]);
|
|
46
|
-
}
|
|
47
|
-
return {};
|
|
48
|
-
})();
|
|
85
|
+
const params = parseParamsOrEmpty(rawParams, m[1]);
|
|
49
86
|
|
|
50
87
|
return { fullMatch: m[0], partialName: m[1], params };
|
|
51
88
|
});
|
|
52
89
|
}
|
|
53
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
|
+
|
|
54
101
|
/**
|
|
55
102
|
* Flatten `{% render %}` partial tags in a template at codegen time.
|
|
56
103
|
*
|
|
@@ -61,11 +108,15 @@ function parseRenderTags(template: string): RenderTag[] {
|
|
|
61
108
|
* All other Liquid expressions (`{{ var }}`, `{% if %}`, `{% for %}`)
|
|
62
109
|
* are preserved for runtime rendering.
|
|
63
110
|
*
|
|
64
|
-
* @param
|
|
65
|
-
* @param partialsDirs - Directories to search for partial `.prompt` files.
|
|
111
|
+
* @param params - Template content and partial directories.
|
|
66
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
|
+
* ```
|
|
67
118
|
*/
|
|
68
|
-
export function flattenPartials(template
|
|
119
|
+
export function flattenPartials({ template, partialsDirs }: FlattenPartialsParams): string {
|
|
69
120
|
const tags = parseRenderTags(template);
|
|
70
121
|
if (tags.length === 0) {
|
|
71
122
|
return template;
|
|
@@ -78,13 +129,8 @@ export function flattenPartials(template: string, partialsDirs: readonly string[
|
|
|
78
129
|
});
|
|
79
130
|
|
|
80
131
|
const result = tags.reduce((acc, tag) => {
|
|
81
|
-
const rendered = engine
|
|
82
|
-
|
|
83
|
-
.map(([k, v]) => `${k}: '${v}'`)
|
|
84
|
-
.join(", ")} %}`,
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
return acc.replace(tag.fullMatch, rendered);
|
|
132
|
+
const rendered = renderPartial(engine, tag);
|
|
133
|
+
return acc.replace(tag.fullMatch, () => rendered);
|
|
88
134
|
}, template);
|
|
89
135
|
|
|
90
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
|
/**
|
|
@@ -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}`);
|
|
@@ -76,37 +94,70 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
|
|
|
76
94
|
);
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
const group
|
|
80
|
-
|
|
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
|
-
})();
|
|
97
|
+
const group = parseGroup(parsed.group, filePath);
|
|
98
|
+
const version = parseVersion(parsed.version);
|
|
98
99
|
|
|
99
100
|
const schema = parseSchemaBlock(parsed.schema, filePath);
|
|
100
101
|
|
|
101
102
|
return { name, group, version, schema };
|
|
102
103
|
}
|
|
103
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
|
+
|
|
104
155
|
/**
|
|
105
156
|
* Parse the `schema` block from frontmatter into an array of variable definitions.
|
|
106
157
|
*
|
|
107
158
|
* @private
|
|
108
159
|
*/
|
|
109
|
-
function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
|
|
160
|
+
function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariable[] {
|
|
110
161
|
if (raw === null || raw === undefined) {
|
|
111
162
|
return [];
|
|
112
163
|
}
|
|
@@ -119,34 +170,27 @@ function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
|
|
|
119
170
|
|
|
120
171
|
const schema = raw as Record<string, unknown>;
|
|
121
172
|
|
|
122
|
-
return Object.entries(schema).map(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
throw new Error(
|
|
148
|
-
`Invalid schema definition for "${varName}" in ${filePath}. ` +
|
|
149
|
-
"Expected a type string or an object with { type, required?, description? }.",
|
|
150
|
-
);
|
|
151
|
-
});
|
|
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
|
+
);
|
|
152
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,56 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
2
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import picomatch from "picomatch";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
3
6
|
|
|
4
7
|
import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
|
|
5
8
|
|
|
6
9
|
const MAX_DEPTH = 5;
|
|
7
10
|
const PROMPT_EXT = ".prompt";
|
|
8
11
|
|
|
12
|
+
/** A `.prompt` file discovered during directory scanning. */
|
|
9
13
|
export interface DiscoveredPrompt {
|
|
10
14
|
readonly name: string;
|
|
11
15
|
readonly filePath: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Options for prompt discovery.
|
|
20
|
+
*/
|
|
21
|
+
export interface DiscoverPromptsOptions {
|
|
22
|
+
/** Glob patterns to scan for `.prompt` files (defaults to `['./**']`). */
|
|
23
|
+
readonly includes: readonly string[];
|
|
24
|
+
/** Glob patterns to exclude from discovery. */
|
|
25
|
+
readonly excludes?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Private
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
14
32
|
/**
|
|
15
33
|
* Extract the `name` field from YAML frontmatter.
|
|
16
34
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
35
|
+
* Uses a proper YAML parser to handle quoted values and edge cases.
|
|
36
|
+
*
|
|
37
|
+
* @private
|
|
19
38
|
*/
|
|
20
39
|
function extractName(content: string): string | undefined {
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
40
|
+
const fmMatch = content.match(FRONTMATTER_RE);
|
|
41
|
+
if (!fmMatch) {
|
|
23
42
|
return undefined;
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
try {
|
|
46
|
+
const parsed = parseYaml(fmMatch[1]) as Record<string, unknown> | null;
|
|
47
|
+
if (parsed !== null && parsed !== undefined && typeof parsed.name === "string") {
|
|
48
|
+
return parsed.name;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
} catch {
|
|
29
52
|
return undefined;
|
|
30
53
|
}
|
|
31
|
-
|
|
32
|
-
return nameLine.slice("name:".length).trim();
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
/**
|
|
@@ -37,6 +58,8 @@ function extractName(content: string): string | undefined {
|
|
|
37
58
|
*
|
|
38
59
|
* If the file is named `prompt.prompt`, uses the parent directory name.
|
|
39
60
|
* Otherwise uses the file stem (e.g. `my-agent.prompt` -> `my-agent`).
|
|
61
|
+
*
|
|
62
|
+
* @private
|
|
40
63
|
*/
|
|
41
64
|
function deriveNameFromPath(filePath: string): string {
|
|
42
65
|
const stem = basename(filePath, PROMPT_EXT);
|
|
@@ -46,8 +69,38 @@ function deriveNameFromPath(filePath: string): string {
|
|
|
46
69
|
return stem;
|
|
47
70
|
}
|
|
48
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Extract the static base directory from a glob pattern.
|
|
74
|
+
*
|
|
75
|
+
* Returns the longest directory prefix before any glob characters
|
|
76
|
+
* (`*`, `?`, `{`, `[`). Falls back to `'.'` if the pattern starts
|
|
77
|
+
* with a glob character.
|
|
78
|
+
*
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
function extractBaseDir(pattern: string): string {
|
|
82
|
+
const globChars = new Set(["*", "?", "{", "["]);
|
|
83
|
+
const parts = pattern.split("/");
|
|
84
|
+
const staticParts: string[] = [];
|
|
85
|
+
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
if ([...part].some((ch) => globChars.has(ch))) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
staticParts.push(part);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (staticParts.length === 0) {
|
|
94
|
+
return ".";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return staticParts.join("/");
|
|
98
|
+
}
|
|
99
|
+
|
|
49
100
|
/**
|
|
50
101
|
* Recursively scan a directory for `.prompt` files.
|
|
102
|
+
*
|
|
103
|
+
* @private
|
|
51
104
|
*/
|
|
52
105
|
function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
53
106
|
if (depth > MAX_DEPTH) {
|
|
@@ -96,23 +149,36 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
96
149
|
}
|
|
97
150
|
|
|
98
151
|
/**
|
|
99
|
-
* Discover all `.prompt` files
|
|
152
|
+
* Discover all `.prompt` files matching the given include/exclude patterns.
|
|
153
|
+
*
|
|
154
|
+
* Extracts base directories from the include patterns, scans them
|
|
155
|
+
* recursively, then filters results through picomatch.
|
|
156
|
+
*
|
|
157
|
+
* Name uniqueness is **not** enforced here — prompts with the same name
|
|
158
|
+
* are allowed as long as they belong to different groups. Uniqueness
|
|
159
|
+
* is validated downstream in the pipeline after frontmatter parsing,
|
|
160
|
+
* where group information is available.
|
|
100
161
|
*
|
|
101
|
-
* @param
|
|
102
|
-
* @returns Sorted
|
|
103
|
-
* @throws If duplicate prompt names are found across roots.
|
|
162
|
+
* @param options - Include and exclude glob patterns.
|
|
163
|
+
* @returns Sorted list of discovered prompts.
|
|
104
164
|
*/
|
|
105
|
-
export function discoverPrompts(
|
|
106
|
-
const
|
|
165
|
+
export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
|
|
166
|
+
const { includes, excludes = [] } = options;
|
|
107
167
|
|
|
108
|
-
const
|
|
168
|
+
const baseDirs = [...new Set(includes.map((pattern) => resolve(extractBaseDir(pattern))))];
|
|
169
|
+
const all = baseDirs.flatMap((dir) => scanDirectory(dir, 0));
|
|
109
170
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
const isIncluded = picomatch(includes as string[]);
|
|
172
|
+
const isExcluded = picomatch(excludes as string[]);
|
|
173
|
+
|
|
174
|
+
const filtered = all.filter((prompt) => {
|
|
175
|
+
const matchPath = relative(process.cwd(), prompt.filePath).replaceAll("\\", "/");
|
|
176
|
+
return isIncluded(matchPath) && !isExcluded(matchPath);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const deduped = [
|
|
180
|
+
...new Map(filtered.map((prompt) => [prompt.filePath, prompt] as const)).values(),
|
|
181
|
+
];
|
|
116
182
|
|
|
117
|
-
return
|
|
183
|
+
return deduped.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
118
184
|
}
|