@funkai/cli 0.2.0 → 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 +32 -0
- package/dist/index.mjs +538 -222
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -2
- package/src/commands/generate.ts +8 -1
- package/src/commands/prompts/create.ts +30 -2
- package/src/commands/prompts/generate.ts +58 -11
- package/src/commands/prompts/lint.ts +41 -7
- package/src/commands/prompts/setup.ts +103 -95
- 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/codegen.ts +113 -43
- package/src/lib/prompts/paths.ts +67 -15
- package/src/lib/prompts/pipeline.ts +82 -7
package/src/commands/setup.ts
CHANGED
|
@@ -1,12 +1,137 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
1
4
|
import { command } from "@kidd-cli/core";
|
|
5
|
+
import { match } from "ts-pattern";
|
|
6
|
+
|
|
7
|
+
import { setupPrompts } from "@/commands/prompts/setup.js";
|
|
8
|
+
|
|
9
|
+
/** @private */
|
|
10
|
+
const CONFIG_TEMPLATE_AGENTS_ONLY = `import { defineConfig } from "@funkai/config";
|
|
11
|
+
|
|
12
|
+
export default defineConfig({
|
|
13
|
+
agents: {},
|
|
14
|
+
});
|
|
15
|
+
`;
|
|
2
16
|
|
|
3
17
|
export default command({
|
|
4
18
|
description: "Set up your project for the funkai SDK",
|
|
5
19
|
async handler(ctx) {
|
|
6
20
|
ctx.logger.intro("funkai — Project Setup");
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
ctx.
|
|
10
|
-
|
|
21
|
+
|
|
22
|
+
// --- Domain selection ---
|
|
23
|
+
const domains = await ctx.prompts.multiselect({
|
|
24
|
+
message: "Which domains do you want to set up?",
|
|
25
|
+
options: [
|
|
26
|
+
{
|
|
27
|
+
value: "prompts" as const,
|
|
28
|
+
label: "Prompts",
|
|
29
|
+
hint: "LiquidJS templating, codegen, IDE integration",
|
|
30
|
+
},
|
|
31
|
+
{ value: "agents" as const, label: "Agents", hint: "Agent scaffolding and configuration" },
|
|
32
|
+
],
|
|
33
|
+
initialValues: ["prompts" as const],
|
|
34
|
+
required: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const hasPrompts = domains.includes("prompts");
|
|
38
|
+
const hasAgents = domains.includes("agents");
|
|
39
|
+
|
|
40
|
+
// --- Create funkai.config.ts ---
|
|
41
|
+
const shouldCreateConfig = await ctx.prompts.confirm({
|
|
42
|
+
message: "Create funkai.config.ts?",
|
|
43
|
+
initialValue: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (shouldCreateConfig) {
|
|
47
|
+
let includes = ["src/prompts/**"];
|
|
48
|
+
let out = ".prompts/client";
|
|
49
|
+
|
|
50
|
+
if (hasPrompts) {
|
|
51
|
+
const includesInput = await ctx.prompts.text({
|
|
52
|
+
message: "Prompt include patterns (comma-separated)",
|
|
53
|
+
defaultValue: "src/prompts/**",
|
|
54
|
+
placeholder: "src/prompts/**",
|
|
55
|
+
});
|
|
56
|
+
includes = includesInput.split(",").map((r) => r.trim());
|
|
57
|
+
|
|
58
|
+
out = await ctx.prompts.text({
|
|
59
|
+
message: "Output directory for generated prompt modules",
|
|
60
|
+
defaultValue: ".prompts/client",
|
|
61
|
+
placeholder: ".prompts/client",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const template = buildConfigTemplate({ hasPrompts, hasAgents, includes, out });
|
|
66
|
+
const configPath = resolve("funkai.config.ts");
|
|
67
|
+
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing config file to project root
|
|
68
|
+
writeFileSync(configPath, template, "utf8");
|
|
69
|
+
ctx.logger.success(`Created ${configPath}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Run domain-specific setup ---
|
|
73
|
+
if (hasPrompts) {
|
|
74
|
+
ctx.logger.info("");
|
|
75
|
+
ctx.logger.info("Configuring Prompts...");
|
|
76
|
+
await setupPrompts(ctx);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (hasAgents) {
|
|
80
|
+
ctx.logger.info("");
|
|
81
|
+
ctx.logger.info("Agents configuration is not yet available.");
|
|
82
|
+
ctx.logger.info("The agents section has been added to your config for future use.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ctx.logger.outro("Project setup complete.");
|
|
11
86
|
},
|
|
12
87
|
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Private
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** @private */
|
|
94
|
+
interface ConfigTemplateOptions {
|
|
95
|
+
readonly hasPrompts: boolean;
|
|
96
|
+
readonly hasAgents: boolean;
|
|
97
|
+
readonly includes: readonly string[];
|
|
98
|
+
readonly out: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @private */
|
|
102
|
+
function buildConfigTemplate({
|
|
103
|
+
hasPrompts,
|
|
104
|
+
hasAgents,
|
|
105
|
+
includes,
|
|
106
|
+
out,
|
|
107
|
+
}: ConfigTemplateOptions): string {
|
|
108
|
+
if (hasPrompts && hasAgents) {
|
|
109
|
+
return buildCustomTemplate(includes, out, true);
|
|
110
|
+
}
|
|
111
|
+
if (hasPrompts) {
|
|
112
|
+
return buildCustomTemplate(includes, out, false);
|
|
113
|
+
}
|
|
114
|
+
return CONFIG_TEMPLATE_AGENTS_ONLY;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @private */
|
|
118
|
+
function buildCustomTemplate(
|
|
119
|
+
includes: readonly string[],
|
|
120
|
+
out: string,
|
|
121
|
+
includeAgents: boolean,
|
|
122
|
+
): string {
|
|
123
|
+
const includesStr = includes.map((r) => `"${r}"`).join(", ");
|
|
124
|
+
const agentsBlock = match(includeAgents)
|
|
125
|
+
.with(true, () => "\n agents: {},\n")
|
|
126
|
+
.with(false, () => "\n")
|
|
127
|
+
.exhaustive();
|
|
128
|
+
|
|
129
|
+
return `import { defineConfig } from "@funkai/config";
|
|
130
|
+
|
|
131
|
+
export default defineConfig({
|
|
132
|
+
prompts: {
|
|
133
|
+
includes: [${includesStr}],
|
|
134
|
+
out: "${out}",
|
|
135
|
+
},${agentsBlock}});
|
|
136
|
+
`;
|
|
137
|
+
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { command } from "@kidd-cli/core";
|
|
2
2
|
|
|
3
3
|
import { handleLint, lintArgs } from "./prompts/lint.js";
|
|
4
|
+
import { getConfig } from "@/config.js";
|
|
4
5
|
|
|
5
6
|
export default command({
|
|
6
7
|
description: "Run all validations across the funkai SDK",
|
|
7
8
|
options: lintArgs,
|
|
8
9
|
handler(ctx) {
|
|
9
10
|
const { silent } = ctx.args;
|
|
11
|
+
const config = getConfig(ctx);
|
|
10
12
|
|
|
11
13
|
// --- Prompts validation ---
|
|
12
14
|
if (!silent) {
|
|
13
15
|
ctx.logger.info("Running prompts validation...");
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
handleLint({
|
|
18
|
+
handleLint({
|
|
19
|
+
args: ctx.args,
|
|
20
|
+
config: config.prompts,
|
|
21
|
+
logger: ctx.logger,
|
|
22
|
+
fail: ctx.fail,
|
|
23
|
+
});
|
|
17
24
|
|
|
18
25
|
// --- Future: agents validation ---
|
|
19
26
|
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FunkaiConfig } from "@funkai/config";
|
|
2
|
+
import { configSchema } from "@funkai/config";
|
|
3
|
+
import type { ConfigType, Context } from "@kidd-cli/core";
|
|
4
|
+
|
|
5
|
+
export { configSchema };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract the typed funkai config from a command context.
|
|
9
|
+
*
|
|
10
|
+
* kidd-cli's `Merge<CliConfig, TConfig>` erases augmented keys when
|
|
11
|
+
* `TConfig` defaults to `Record<string, unknown>`, so we cast here.
|
|
12
|
+
*
|
|
13
|
+
* @param ctx - The CLI context.
|
|
14
|
+
* @returns The typed funkai configuration.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const config = getConfig(ctx);
|
|
19
|
+
* const promptsConfig = config.prompts;
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function getConfig(ctx: Pick<Context, "config">): Readonly<FunkaiConfig> {
|
|
23
|
+
return ctx.config as unknown as Readonly<FunkaiConfig>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module "@kidd-cli/core" {
|
|
27
|
+
interface CliConfig extends ConfigType<typeof configSchema> {}
|
|
28
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import promptsLint from "@/commands/prompts/lint.js";
|
|
|
10
10
|
import promptsSetup from "@/commands/prompts/setup.js";
|
|
11
11
|
import setup from "@/commands/setup.js";
|
|
12
12
|
import validate from "@/commands/validate.js";
|
|
13
|
+
import { configSchema } from "@/config.js";
|
|
13
14
|
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const packageJson = require("../package.json") as { readonly version: string };
|
|
@@ -18,6 +19,9 @@ await cli({
|
|
|
18
19
|
description: "CLI for the funkai AI SDK framework",
|
|
19
20
|
name: "funkai",
|
|
20
21
|
version: packageJson.version,
|
|
22
|
+
config: {
|
|
23
|
+
schema: configSchema,
|
|
24
|
+
},
|
|
21
25
|
commands: {
|
|
22
26
|
generate,
|
|
23
27
|
setup,
|
|
@@ -67,6 +67,14 @@ function formatGroupValue(group: string | undefined): string {
|
|
|
67
67
|
return "undefined";
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** @private */
|
|
71
|
+
function formatGroupJsdoc(group: string | undefined): readonly string[] {
|
|
72
|
+
if (group) {
|
|
73
|
+
return [" *", ` * @group ${group}`];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
/** @private */
|
|
71
79
|
function parseGroupSegments(group: string | undefined): readonly string[] {
|
|
72
80
|
if (group) {
|
|
@@ -98,34 +106,79 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
|
|
|
98
106
|
return `z.object({\n${fields}\n})`;
|
|
99
107
|
}
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
|
111
161
|
|
|
112
162
|
/**
|
|
113
163
|
* Generate a per-prompt TypeScript module with a default export.
|
|
114
164
|
*
|
|
115
|
-
* The module
|
|
116
|
-
*
|
|
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.
|
|
117
170
|
*/
|
|
118
171
|
export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
119
172
|
const escaped = escapeTemplateLiteral(prompt.template);
|
|
120
173
|
const schemaExpr = generateSchemaExpression(prompt.schema);
|
|
121
174
|
const groupValue = formatGroupValue(prompt.group);
|
|
175
|
+
const header = formatHeader(prompt.sourcePath);
|
|
122
176
|
|
|
123
177
|
const lines: readonly string[] = [
|
|
124
|
-
|
|
125
|
-
`// Source: ${prompt.sourcePath}`,
|
|
178
|
+
header,
|
|
126
179
|
"",
|
|
127
180
|
"import { z } from 'zod'",
|
|
128
|
-
"import {
|
|
181
|
+
"import { createPrompt } from '@funkai/prompts'",
|
|
129
182
|
"",
|
|
130
183
|
`const schema = ${schemaExpr}`,
|
|
131
184
|
"",
|
|
@@ -133,28 +186,16 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
|
|
|
133
186
|
"",
|
|
134
187
|
`const template = \`${escaped}\``,
|
|
135
188
|
"",
|
|
136
|
-
"
|
|
137
|
-
`
|
|
189
|
+
"/**",
|
|
190
|
+
` * **${prompt.name}** prompt module.`,
|
|
191
|
+
...formatGroupJsdoc(prompt.group),
|
|
192
|
+
" */",
|
|
193
|
+
"export default createPrompt<Variables>({",
|
|
194
|
+
` name: '${prompt.name}',`,
|
|
138
195
|
` group: ${groupValue},`,
|
|
196
|
+
" template,",
|
|
139
197
|
" schema,",
|
|
140
|
-
|
|
141
|
-
.with(0, () => [
|
|
142
|
-
" render(variables?: undefined): string {",
|
|
143
|
-
" return liquidEngine.parseAndRenderSync(template, {})",
|
|
144
|
-
" },",
|
|
145
|
-
" validate(variables?: undefined): Variables {",
|
|
146
|
-
" return schema.parse(variables ?? {})",
|
|
147
|
-
" },",
|
|
148
|
-
])
|
|
149
|
-
.otherwise(() => [
|
|
150
|
-
" render(variables: Variables): string {",
|
|
151
|
-
" return liquidEngine.parseAndRenderSync(template, schema.parse(variables))",
|
|
152
|
-
" },",
|
|
153
|
-
" validate(variables: unknown): Variables {",
|
|
154
|
-
" return schema.parse(variables)",
|
|
155
|
-
" },",
|
|
156
|
-
]),
|
|
157
|
-
"}",
|
|
198
|
+
"})",
|
|
158
199
|
"",
|
|
159
200
|
];
|
|
160
201
|
|
|
@@ -172,6 +213,9 @@ interface TreeNode {
|
|
|
172
213
|
/**
|
|
173
214
|
* Build a nested tree from sorted prompts, grouped by their `group` field.
|
|
174
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
|
+
*
|
|
175
219
|
* @param prompts - Sorted parsed prompts.
|
|
176
220
|
* @returns A tree where leaves are import names and branches are group namespaces.
|
|
177
221
|
* @throws If a prompt name collides with a group namespace at the same level.
|
|
@@ -180,7 +224,8 @@ interface TreeNode {
|
|
|
180
224
|
*/
|
|
181
225
|
function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
182
226
|
return prompts.reduce<Record<string, unknown>>((root, prompt) => {
|
|
183
|
-
const
|
|
227
|
+
const leafKey = toCamelCase(prompt.name);
|
|
228
|
+
const importName = toImportName(prompt.name, prompt.group);
|
|
184
229
|
const segments = parseGroupSegments(prompt.group);
|
|
185
230
|
|
|
186
231
|
const target = segments.reduce<Record<string, unknown>>((current, segment) => {
|
|
@@ -196,13 +241,13 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
|
|
|
196
241
|
return current[segment] as Record<string, unknown>;
|
|
197
242
|
}, root);
|
|
198
243
|
|
|
199
|
-
if (typeof target[
|
|
244
|
+
if (typeof target[leafKey] === "object" && target[leafKey] !== null) {
|
|
200
245
|
throw new Error(
|
|
201
|
-
`Collision: prompt "${
|
|
246
|
+
`Collision: prompt "${leafKey}" conflicts with existing group namespace "${leafKey}" at the same level.`,
|
|
202
247
|
);
|
|
203
248
|
}
|
|
204
249
|
|
|
205
|
-
target[
|
|
250
|
+
target[leafKey] = importName;
|
|
206
251
|
return root;
|
|
207
252
|
}, {}) as TreeNode;
|
|
208
253
|
}
|
|
@@ -221,7 +266,12 @@ function serializeTree(node: TreeNode, indent: number): readonly string[] {
|
|
|
221
266
|
|
|
222
267
|
return Object.entries(node).flatMap(([key, value]) =>
|
|
223
268
|
match(typeof value)
|
|
224
|
-
.with("string", () =>
|
|
269
|
+
.with("string", () => {
|
|
270
|
+
if (key === value) {
|
|
271
|
+
return [`${pad}${key},`];
|
|
272
|
+
}
|
|
273
|
+
return [`${pad}${key}: ${value as string},`];
|
|
274
|
+
})
|
|
225
275
|
.otherwise(() => [
|
|
226
276
|
`${pad}${key}: {`,
|
|
227
277
|
...serializeTree(value as TreeNode, indent + 1),
|
|
@@ -236,19 +286,39 @@ function serializeTree(node: TreeNode, indent: number): readonly 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
320
|
const lines: readonly string[] = [
|
|
251
|
-
|
|
321
|
+
header,
|
|
252
322
|
"",
|
|
253
323
|
"import { createPromptRegistry } from '@funkai/prompts'",
|
|
254
324
|
imports,
|
package/src/lib/prompts/paths.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
3
|
|
|
4
|
+
import picomatch from "picomatch";
|
|
4
5
|
import { parse as parseYaml } from "yaml";
|
|
5
6
|
|
|
6
7
|
import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
|
|
@@ -14,6 +15,16 @@ export interface DiscoveredPrompt {
|
|
|
14
15
|
readonly filePath: string;
|
|
15
16
|
}
|
|
16
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
|
+
|
|
17
28
|
// ---------------------------------------------------------------------------
|
|
18
29
|
// Private
|
|
19
30
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +69,34 @@ function deriveNameFromPath(filePath: string): string {
|
|
|
58
69
|
return stem;
|
|
59
70
|
}
|
|
60
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
|
+
|
|
61
100
|
/**
|
|
62
101
|
* Recursively scan a directory for `.prompt` files.
|
|
63
102
|
*
|
|
@@ -110,23 +149,36 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
110
149
|
}
|
|
111
150
|
|
|
112
151
|
/**
|
|
113
|
-
* Discover all `.prompt` files
|
|
152
|
+
* Discover all `.prompt` files matching the given include/exclude patterns.
|
|
114
153
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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.
|
|
161
|
+
*
|
|
162
|
+
* @param options - Include and exclude glob patterns.
|
|
163
|
+
* @returns Sorted list of discovered prompts.
|
|
118
164
|
*/
|
|
119
|
-
export function discoverPrompts(
|
|
120
|
-
const
|
|
165
|
+
export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
|
|
166
|
+
const { includes, excludes = [] } = options;
|
|
121
167
|
|
|
122
|
-
const
|
|
168
|
+
const baseDirs = [...new Set(includes.map((pattern) => resolve(extractBaseDir(pattern))))];
|
|
169
|
+
const all = baseDirs.flatMap((dir) => scanDirectory(dir, 0));
|
|
123
170
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
];
|
|
130
182
|
|
|
131
|
-
return
|
|
183
|
+
return deduped.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
132
184
|
}
|