@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.
@@ -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 name - Prompt name (for error messages).
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: string,
35
- filePath: string,
36
- schemaVars: readonly SchemaVariable[],
37
- templateVars: readonly string[],
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
 
@@ -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 parsed = parseYaml(fmMatch[1]) as Record<string, unknown> | null;
36
- if (parsed !== null && parsed !== undefined && typeof parsed.name === "string") {
37
- return parsed.name;
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 from the given root directories.
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
- * @param roots - Directories to scan recursively.
116
- * @returns Sorted, deduplicated list of discovered prompts.
117
- * @throws If duplicate prompt names are found across roots.
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(roots: readonly string[]): readonly DiscoveredPrompt[] {
120
- const all = roots.flatMap((root) => scanDirectory(resolve(root), 0));
166
+ export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
167
+ const { includes, excludes = [] } = options;
121
168
 
122
- const byName = Map.groupBy(all, (prompt) => prompt.name);
169
+ const baseDirs = [...new Set(includes.map((pattern) => resolve(extractBaseDir(pattern))))];
170
+ const all = baseDirs.flatMap((dir) => scanDirectory(dir, 0));
123
171
 
124
- const duplicate = [...byName.entries()].find(([, prompts]) => prompts.length > 1);
125
- if (duplicate) {
126
- const [name, prompts] = duplicate;
127
- const paths = prompts.map((p) => p.filePath).join("\n ");
128
- throw new Error(`Duplicate prompt name "${name}" found in:\n ${paths}`);
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 all.toSorted((a, b) => a.name.localeCompare(b.name));
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 roots: readonly string[];
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 discovered = discoverPrompts([...options.roots]);
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(frontmatter.name, d.filePath, frontmatter.schema, templateVars);
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 roots: readonly string[];
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 discovered = discoverPrompts([...options.roots]);
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(frontmatter.name, d.filePath, frontmatter.schema, templateVars),
108
- prompt: {
203
+ lintResult: lintPrompt({
109
204
  name: frontmatter.name,
110
- group: frontmatter.group,
111
- schema: frontmatter.schema,
112
- template,
113
- sourcePath: d.filePath,
114
- } satisfies ParsedPrompt,
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: processed.map((p) => p.prompt),
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
- "esModuleInterop": true,
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
- "resolveJsonModule": true,
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": {