@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.
@@ -1,8 +1,11 @@
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 picomatch from "picomatch";
5
7
 
8
+ import { toFileSlug } from "./codegen.js";
6
9
  import type { ParsedPrompt } from "./codegen.js";
7
10
  import { extractVariables } from "./extract-variables.js";
8
11
  import { flattenPartials } from "./flatten.js";
@@ -11,6 +14,54 @@ import { lintPrompt } from "./lint.js";
11
14
  import type { LintResult } from "./lint.js";
12
15
  import { discoverPrompts } from "./paths.js";
13
16
 
17
+ /**
18
+ * Validate that no two prompts share the same group+name combination.
19
+ *
20
+ * @param prompts - Parsed prompts with group and name fields.
21
+ * @throws If duplicate group+name combinations are found.
22
+ *
23
+ * @private
24
+ */
25
+ function validateUniqueness(prompts: readonly ParsedPrompt[]): void {
26
+ const bySlug = Map.groupBy(prompts, (p) => toFileSlug(p.name, p.group));
27
+ const duplicate = [...bySlug.entries()].find(([, entries]) => entries.length > 1);
28
+
29
+ if (duplicate) {
30
+ const [slug, entries] = duplicate;
31
+ const paths = entries.map((p) => p.sourcePath).join("\n ");
32
+ throw new Error(`Duplicate prompt "${slug}" (group+name) found in:\n ${paths}`);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve a prompt's group from config-defined group patterns.
38
+ *
39
+ * Matches the prompt's file path against each group's `includes`/`excludes`
40
+ * patterns. First matching group wins.
41
+ *
42
+ * @param filePath - Absolute path to the prompt file.
43
+ * @param groups - Config-defined group definitions.
44
+ * @returns The matching group name, or undefined if no match.
45
+ *
46
+ * @private
47
+ */
48
+ function resolveGroupFromConfig(
49
+ filePath: string,
50
+ groups: readonly PromptGroup[],
51
+ ): string | undefined {
52
+ const matchPath = relative(process.cwd(), filePath).replaceAll("\\", "/");
53
+
54
+ for (const group of groups) {
55
+ const isIncluded = picomatch(group.includes as string[]);
56
+ const isExcluded = picomatch((group.excludes ?? []) as string[]);
57
+
58
+ if (isIncluded(matchPath) && !isExcluded(matchPath)) {
59
+ return group.name;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+
14
65
  /**
15
66
  * Resolve the list of partial directories to search.
16
67
  *
@@ -30,7 +81,8 @@ function resolvePartialsDirs(customDir: string): readonly string[] {
30
81
  * Options for the prompts lint pipeline.
31
82
  */
32
83
  export interface LintPipelineOptions {
33
- readonly roots: readonly string[];
84
+ readonly includes: readonly string[];
85
+ readonly excludes?: readonly string[];
34
86
  readonly partials?: string;
35
87
  }
36
88
 
@@ -49,7 +101,14 @@ export interface LintPipelineResult {
49
101
  * @returns Lint results for all discovered prompts.
50
102
  */
51
103
  export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
52
- const discovered = discoverPrompts([...options.roots]);
104
+ let excludes: string[] | undefined;
105
+ if (options.excludes) {
106
+ excludes = [...options.excludes];
107
+ }
108
+ const discovered = discoverPrompts({
109
+ includes: [...options.includes],
110
+ excludes,
111
+ });
53
112
  const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
54
113
  const partialsDirs = resolvePartialsDirs(customPartialsDir);
55
114
 
@@ -69,9 +128,11 @@ export function runLintPipeline(options: LintPipelineOptions): LintPipelineResul
69
128
  * Options for the prompts generate pipeline.
70
129
  */
71
130
  export interface GeneratePipelineOptions {
72
- readonly roots: readonly string[];
131
+ readonly includes: readonly string[];
132
+ readonly excludes?: readonly string[];
73
133
  readonly out: string;
74
134
  readonly partials?: string;
135
+ readonly groups?: readonly PromptGroup[];
75
136
  }
76
137
 
77
138
  /**
@@ -92,9 +153,17 @@ export interface GeneratePipelineResult {
92
153
  * @returns Parsed prompts ready for code generation, along with lint results.
93
154
  */
94
155
  export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
95
- const discovered = discoverPrompts([...options.roots]);
156
+ let excludes: string[] | undefined;
157
+ if (options.excludes) {
158
+ excludes = [...options.excludes];
159
+ }
160
+ const discovered = discoverPrompts({
161
+ includes: [...options.includes],
162
+ excludes,
163
+ });
96
164
  const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
97
165
  const partialsDirs = resolvePartialsDirs(customPartialsDir);
166
+ const configGroups = options.groups ?? [];
98
167
 
99
168
  const processed = discovered.map((d) => {
100
169
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
@@ -103,11 +172,14 @@ export function runGeneratePipeline(options: GeneratePipelineOptions): GenerateP
103
172
  const template = flattenPartials({ template: clean(raw), partialsDirs });
104
173
  const templateVars = extractVariables(template);
105
174
 
175
+ // Frontmatter group wins; fall back to config-defined group patterns
176
+ const group = frontmatter.group ?? resolveGroupFromConfig(d.filePath, configGroups);
177
+
106
178
  return {
107
179
  lintResult: lintPrompt(frontmatter.name, d.filePath, frontmatter.schema, templateVars),
108
180
  prompt: {
109
181
  name: frontmatter.name,
110
- group: frontmatter.group,
182
+ group,
111
183
  schema: frontmatter.schema,
112
184
  template,
113
185
  sourcePath: d.filePath,
@@ -115,9 +187,12 @@ export function runGeneratePipeline(options: GeneratePipelineOptions): GenerateP
115
187
  };
116
188
  });
117
189
 
190
+ const prompts = processed.map((p) => p.prompt);
191
+ validateUniqueness(prompts);
192
+
118
193
  return {
119
194
  discovered: discovered.length,
120
195
  lintResults: processed.map((p) => p.lintResult),
121
- prompts: processed.map((p) => p.prompt),
196
+ prompts,
122
197
  };
123
198
  }