@funkai/cli 0.2.0 → 0.3.1
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 +39 -0
- package/README.md +11 -11
- package/dist/index.mjs +640 -267
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/commands/generate.ts +23 -1
- package/src/commands/prompts/create.ts +33 -2
- package/src/commands/prompts/generate.ts +97 -12
- package/src/commands/prompts/lint.ts +69 -7
- package/src/commands/prompts/setup.ts +103 -95
- package/src/commands/setup.ts +151 -4
- package/src/commands/validate.ts +20 -2
- package/src/config.ts +28 -0
- package/src/index.ts +4 -0
- package/src/lib/prompts/__tests__/lint.test.ts +36 -24
- package/src/lib/prompts/codegen.ts +112 -43
- package/src/lib/prompts/flatten.ts +10 -5
- package/src/lib/prompts/frontmatter.ts +24 -8
- package/src/lib/prompts/lint.ts +31 -10
- package/src/lib/prompts/paths.ts +71 -18
- package/src/lib/prompts/pipeline.ts +112 -14
- package/tsconfig.json +11 -3
package/src/lib/prompts/lint.ts
CHANGED
|
@@ -17,6 +17,20 @@ export interface LintResult {
|
|
|
17
17
|
readonly diagnostics: readonly LintDiagnostic[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Parameters for linting a single prompt.
|
|
22
|
+
*/
|
|
23
|
+
export interface LintPromptParams {
|
|
24
|
+
/** Prompt name (for error messages). */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** Source file path (for error messages). */
|
|
27
|
+
readonly filePath: string;
|
|
28
|
+
/** Variables declared in frontmatter schema. */
|
|
29
|
+
readonly schemaVars: readonly SchemaVariable[];
|
|
30
|
+
/** Variables extracted from the template body. */
|
|
31
|
+
readonly templateVars: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/**
|
|
21
35
|
* Lint a prompt by comparing declared schema variables against
|
|
22
36
|
* variables actually used in the template body.
|
|
@@ -24,18 +38,25 @@ export interface LintResult {
|
|
|
24
38
|
* - **Error**: template uses a variable NOT declared in the schema (undefined var).
|
|
25
39
|
* - **Warn**: schema declares a variable NOT used in the template (unused var).
|
|
26
40
|
*
|
|
27
|
-
* @param
|
|
28
|
-
* @param filePath - Source file path (for error messages).
|
|
29
|
-
* @param schemaVars - Variables declared in frontmatter schema.
|
|
30
|
-
* @param templateVars - Variables extracted from the template body.
|
|
41
|
+
* @param params - Lint prompt parameters.
|
|
31
42
|
* @returns Lint result with diagnostics.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const result = lintPrompt({
|
|
47
|
+
* name: 'greeting',
|
|
48
|
+
* filePath: 'prompts/greeting.prompt',
|
|
49
|
+
* schemaVars: [{ name: 'name', type: 'string', required: true }],
|
|
50
|
+
* templateVars: ['name'],
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
32
53
|
*/
|
|
33
|
-
export function lintPrompt(
|
|
34
|
-
name
|
|
35
|
-
filePath
|
|
36
|
-
schemaVars
|
|
37
|
-
templateVars
|
|
38
|
-
): LintResult {
|
|
54
|
+
export function lintPrompt({
|
|
55
|
+
name,
|
|
56
|
+
filePath,
|
|
57
|
+
schemaVars,
|
|
58
|
+
templateVars,
|
|
59
|
+
}: LintPromptParams): LintResult {
|
|
39
60
|
const declared = new Set(schemaVars.map((v) => v.name));
|
|
40
61
|
const used = new Set(templateVars);
|
|
41
62
|
|
package/src/lib/prompts/paths.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { match } from "ts-pattern";
|
|
4
6
|
import { parse as parseYaml } from "yaml";
|
|
5
7
|
|
|
6
8
|
import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
|
|
@@ -14,6 +16,16 @@ export interface DiscoveredPrompt {
|
|
|
14
16
|
readonly filePath: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Options for prompt discovery.
|
|
21
|
+
*/
|
|
22
|
+
export interface DiscoverPromptsOptions {
|
|
23
|
+
/** Glob patterns to scan for `.prompt` files (defaults to `['./**']`). */
|
|
24
|
+
readonly includes: readonly string[];
|
|
25
|
+
/** Glob patterns to exclude from discovery. */
|
|
26
|
+
readonly excludes?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
// ---------------------------------------------------------------------------
|
|
18
30
|
// Private
|
|
19
31
|
// ---------------------------------------------------------------------------
|
|
@@ -32,9 +44,13 @@ function extractName(content: string): string | undefined {
|
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
try {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
return
|
|
47
|
+
const [, fmContent] = fmMatch;
|
|
48
|
+
if (fmContent === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const parsed = parseYaml(fmContent) as Record<string, unknown> | null;
|
|
52
|
+
if (parsed !== null && parsed !== undefined && typeof parsed["name"] === "string") {
|
|
53
|
+
return parsed["name"];
|
|
38
54
|
}
|
|
39
55
|
return undefined;
|
|
40
56
|
} catch {
|
|
@@ -58,6 +74,30 @@ function deriveNameFromPath(filePath: string): string {
|
|
|
58
74
|
return stem;
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Extract the static base directory from a glob pattern.
|
|
79
|
+
*
|
|
80
|
+
* Returns the longest directory prefix before any glob characters
|
|
81
|
+
* (`*`, `?`, `{`, `[`). Falls back to `'.'` if the pattern starts
|
|
82
|
+
* with a glob character.
|
|
83
|
+
*
|
|
84
|
+
* @private
|
|
85
|
+
*/
|
|
86
|
+
function extractBaseDir(pattern: string): string {
|
|
87
|
+
const globChars = new Set(["*", "?", "{", "["]);
|
|
88
|
+
const parts = pattern.split("/");
|
|
89
|
+
const firstGlobIndex = parts.findIndex((part) => [...part].some((ch) => globChars.has(ch)));
|
|
90
|
+
const staticParts = match(firstGlobIndex)
|
|
91
|
+
.with(-1, () => parts)
|
|
92
|
+
.otherwise(() => parts.slice(0, firstGlobIndex));
|
|
93
|
+
|
|
94
|
+
if (staticParts.length === 0) {
|
|
95
|
+
return ".";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return staticParts.join("/");
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
/**
|
|
62
102
|
* Recursively scan a directory for `.prompt` files.
|
|
63
103
|
*
|
|
@@ -110,23 +150,36 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
|
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
/**
|
|
113
|
-
* Discover all `.prompt` files
|
|
153
|
+
* Discover all `.prompt` files matching the given include/exclude patterns.
|
|
154
|
+
*
|
|
155
|
+
* Extracts base directories from the include patterns, scans them
|
|
156
|
+
* recursively, then filters results through picomatch.
|
|
114
157
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
158
|
+
* Name uniqueness is **not** enforced here — prompts with the same name
|
|
159
|
+
* are allowed as long as they belong to different groups. Uniqueness
|
|
160
|
+
* is validated downstream in the pipeline after frontmatter parsing,
|
|
161
|
+
* where group information is available.
|
|
162
|
+
*
|
|
163
|
+
* @param options - Include and exclude glob patterns.
|
|
164
|
+
* @returns Sorted list of discovered prompts.
|
|
118
165
|
*/
|
|
119
|
-
export function discoverPrompts(
|
|
120
|
-
const
|
|
166
|
+
export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
|
|
167
|
+
const { includes, excludes = [] } = options;
|
|
121
168
|
|
|
122
|
-
const
|
|
169
|
+
const baseDirs = [...new Set(includes.map((pattern) => resolve(extractBaseDir(pattern))))];
|
|
170
|
+
const all = baseDirs.flatMap((dir) => scanDirectory(dir, 0));
|
|
123
171
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
const isIncluded = picomatch(includes as string[]);
|
|
173
|
+
const isExcluded = picomatch(excludes as string[]);
|
|
174
|
+
|
|
175
|
+
const filtered = all.filter((prompt) => {
|
|
176
|
+
const matchPath = relative(process.cwd(), prompt.filePath).replaceAll("\\", "/");
|
|
177
|
+
return isIncluded(matchPath) && !isExcluded(matchPath);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const deduped = [
|
|
181
|
+
...new Map(filtered.map((prompt) => [prompt.filePath, prompt] as const)).values(),
|
|
182
|
+
];
|
|
130
183
|
|
|
131
|
-
return
|
|
184
|
+
return deduped.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
132
185
|
}
|
|
@@ -1,16 +1,71 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import type { PromptGroup } from "@funkai/config";
|
|
4
5
|
import { clean, PARTIALS_DIR } from "@funkai/prompts/cli";
|
|
6
|
+
import { isNil } from "es-toolkit";
|
|
7
|
+
import picomatch from "picomatch";
|
|
5
8
|
|
|
9
|
+
import { toFileSlug } from "./codegen.js";
|
|
6
10
|
import type { ParsedPrompt } from "./codegen.js";
|
|
7
11
|
import { extractVariables } from "./extract-variables.js";
|
|
8
12
|
import { flattenPartials } from "./flatten.js";
|
|
9
13
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
14
|
+
import type { SchemaVariable } from "./frontmatter.js";
|
|
10
15
|
import { lintPrompt } from "./lint.js";
|
|
11
16
|
import type { LintResult } from "./lint.js";
|
|
12
17
|
import { discoverPrompts } from "./paths.js";
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Validate that no two prompts share the same group+name combination.
|
|
21
|
+
*
|
|
22
|
+
* @param prompts - Parsed prompts with group and name fields.
|
|
23
|
+
* @throws If duplicate group+name combinations are found.
|
|
24
|
+
*
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
function validateUniqueness(prompts: readonly ParsedPrompt[]): void {
|
|
28
|
+
const bySlug = Map.groupBy(prompts, (p) => toFileSlug(p.name, p.group));
|
|
29
|
+
const duplicate = [...bySlug.entries()].find(([, entries]) => entries.length > 1);
|
|
30
|
+
|
|
31
|
+
if (duplicate) {
|
|
32
|
+
const [slug, entries] = duplicate;
|
|
33
|
+
const paths = entries.map((p) => p.sourcePath).join("\n ");
|
|
34
|
+
throw new Error(`Duplicate prompt "${slug}" (group+name) found in:\n ${paths}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a prompt's group from config-defined group patterns.
|
|
40
|
+
*
|
|
41
|
+
* Matches the prompt's file path against each group's `includes`/`excludes`
|
|
42
|
+
* patterns. First matching group wins.
|
|
43
|
+
*
|
|
44
|
+
* @param filePath - Absolute path to the prompt file.
|
|
45
|
+
* @param groups - Config-defined group definitions.
|
|
46
|
+
* @returns The matching group name, or undefined if no match.
|
|
47
|
+
*
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
function resolveGroupFromConfig(
|
|
51
|
+
filePath: string,
|
|
52
|
+
groups: readonly PromptGroup[],
|
|
53
|
+
): string | undefined {
|
|
54
|
+
const matchPath = relative(process.cwd(), filePath).replaceAll("\\", "/");
|
|
55
|
+
|
|
56
|
+
const matched = groups.find((group) => {
|
|
57
|
+
const isIncluded = picomatch(group.includes as string[]);
|
|
58
|
+
const isExcluded = picomatch((group.excludes ?? []) as string[]);
|
|
59
|
+
return isIncluded(matchPath) && !isExcluded(matchPath);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (isNil(matched)) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return matched.name;
|
|
67
|
+
}
|
|
68
|
+
|
|
14
69
|
/**
|
|
15
70
|
* Resolve the list of partial directories to search.
|
|
16
71
|
*
|
|
@@ -30,7 +85,8 @@ function resolvePartialsDirs(customDir: string): readonly string[] {
|
|
|
30
85
|
* Options for the prompts lint pipeline.
|
|
31
86
|
*/
|
|
32
87
|
export interface LintPipelineOptions {
|
|
33
|
-
readonly
|
|
88
|
+
readonly includes: readonly string[];
|
|
89
|
+
readonly excludes?: readonly string[];
|
|
34
90
|
readonly partials?: string;
|
|
35
91
|
}
|
|
36
92
|
|
|
@@ -49,7 +105,14 @@ export interface LintPipelineResult {
|
|
|
49
105
|
* @returns Lint results for all discovered prompts.
|
|
50
106
|
*/
|
|
51
107
|
export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
|
|
52
|
-
const
|
|
108
|
+
const discoverLintOptions: {
|
|
109
|
+
includes: string[];
|
|
110
|
+
excludes?: string[];
|
|
111
|
+
} = { includes: [...options.includes] };
|
|
112
|
+
if (options.excludes !== undefined) {
|
|
113
|
+
discoverLintOptions.excludes = [...options.excludes];
|
|
114
|
+
}
|
|
115
|
+
const discovered = discoverPrompts(discoverLintOptions);
|
|
53
116
|
const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
|
|
54
117
|
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
55
118
|
|
|
@@ -59,7 +122,12 @@ export function runLintPipeline(options: LintPipelineOptions): LintPipelineResul
|
|
|
59
122
|
const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
|
|
60
123
|
const template = flattenPartials({ template: clean(raw), partialsDirs });
|
|
61
124
|
const templateVars = extractVariables(template);
|
|
62
|
-
return lintPrompt(
|
|
125
|
+
return lintPrompt({
|
|
126
|
+
name: frontmatter.name,
|
|
127
|
+
filePath: d.filePath,
|
|
128
|
+
schemaVars: frontmatter.schema,
|
|
129
|
+
templateVars,
|
|
130
|
+
});
|
|
63
131
|
});
|
|
64
132
|
|
|
65
133
|
return { discovered: discovered.length, results };
|
|
@@ -69,9 +137,11 @@ export function runLintPipeline(options: LintPipelineOptions): LintPipelineResul
|
|
|
69
137
|
* Options for the prompts generate pipeline.
|
|
70
138
|
*/
|
|
71
139
|
export interface GeneratePipelineOptions {
|
|
72
|
-
readonly
|
|
140
|
+
readonly includes: readonly string[];
|
|
141
|
+
readonly excludes?: readonly string[];
|
|
73
142
|
readonly out: string;
|
|
74
143
|
readonly partials?: string;
|
|
144
|
+
readonly groups?: readonly PromptGroup[];
|
|
75
145
|
}
|
|
76
146
|
|
|
77
147
|
/**
|
|
@@ -92,9 +162,17 @@ export interface GeneratePipelineResult {
|
|
|
92
162
|
* @returns Parsed prompts ready for code generation, along with lint results.
|
|
93
163
|
*/
|
|
94
164
|
export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
|
|
95
|
-
const
|
|
165
|
+
const discoverGenerateOptions: {
|
|
166
|
+
includes: string[];
|
|
167
|
+
excludes?: string[];
|
|
168
|
+
} = { includes: [...options.includes] };
|
|
169
|
+
if (options.excludes !== undefined) {
|
|
170
|
+
discoverGenerateOptions.excludes = [...options.excludes];
|
|
171
|
+
}
|
|
172
|
+
const discovered = discoverPrompts(discoverGenerateOptions);
|
|
96
173
|
const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
|
|
97
174
|
const partialsDirs = resolvePartialsDirs(customPartialsDir);
|
|
175
|
+
const configGroups = options.groups ?? [];
|
|
98
176
|
|
|
99
177
|
const processed = discovered.map((d) => {
|
|
100
178
|
// oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
|
|
@@ -103,21 +181,41 @@ export function runGeneratePipeline(options: GeneratePipelineOptions): GenerateP
|
|
|
103
181
|
const template = flattenPartials({ template: clean(raw), partialsDirs });
|
|
104
182
|
const templateVars = extractVariables(template);
|
|
105
183
|
|
|
184
|
+
// Frontmatter group wins; fall back to config-defined group patterns
|
|
185
|
+
const group = frontmatter.group ?? resolveGroupFromConfig(d.filePath, configGroups);
|
|
186
|
+
|
|
187
|
+
const promptObj: {
|
|
188
|
+
name: string;
|
|
189
|
+
schema: readonly SchemaVariable[];
|
|
190
|
+
template: string;
|
|
191
|
+
sourcePath: string;
|
|
192
|
+
group?: string;
|
|
193
|
+
} = {
|
|
194
|
+
name: frontmatter.name,
|
|
195
|
+
schema: frontmatter.schema,
|
|
196
|
+
template,
|
|
197
|
+
sourcePath: d.filePath,
|
|
198
|
+
};
|
|
199
|
+
if (group !== undefined) {
|
|
200
|
+
promptObj.group = group;
|
|
201
|
+
}
|
|
106
202
|
return {
|
|
107
|
-
lintResult: lintPrompt(
|
|
108
|
-
prompt: {
|
|
203
|
+
lintResult: lintPrompt({
|
|
109
204
|
name: frontmatter.name,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
205
|
+
filePath: d.filePath,
|
|
206
|
+
schemaVars: frontmatter.schema,
|
|
207
|
+
templateVars,
|
|
208
|
+
}),
|
|
209
|
+
prompt: promptObj satisfies ParsedPrompt,
|
|
115
210
|
};
|
|
116
211
|
});
|
|
117
212
|
|
|
213
|
+
const prompts = processed.map((p) => p.prompt);
|
|
214
|
+
validateUniqueness(prompts);
|
|
215
|
+
|
|
118
216
|
return {
|
|
119
217
|
discovered: discovered.length,
|
|
120
218
|
lintResults: processed.map((p) => p.lintResult),
|
|
121
|
-
prompts
|
|
219
|
+
prompts,
|
|
122
220
|
};
|
|
123
221
|
}
|
package/tsconfig.json
CHANGED
|
@@ -5,15 +5,23 @@
|
|
|
5
5
|
"module": "NodeNext",
|
|
6
6
|
"moduleResolution": "NodeNext",
|
|
7
7
|
"lib": ["ES2024"],
|
|
8
|
+
"types": ["node"],
|
|
9
|
+
|
|
8
10
|
"strict": true,
|
|
9
|
-
"
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"exactOptionalPropertyTypes": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
15
|
+
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
10
18
|
"skipLibCheck": true,
|
|
11
19
|
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
|
|
13
|
-
"isolatedModules": true,
|
|
20
|
+
|
|
14
21
|
"declaration": true,
|
|
15
22
|
"declarationMap": true,
|
|
16
23
|
"sourceMap": true,
|
|
24
|
+
|
|
17
25
|
"outDir": "./dist",
|
|
18
26
|
"rootDir": ".",
|
|
19
27
|
"paths": {
|