@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.
@@ -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,9 +14,58 @@ 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
  *
68
+ * @private
17
69
  * @param customDir - Custom partials directory path.
18
70
  * @returns Array of directories to search for partials.
19
71
  */
@@ -29,7 +81,8 @@ function resolvePartialsDirs(customDir: string): readonly string[] {
29
81
  * Options for the prompts lint pipeline.
30
82
  */
31
83
  export interface LintPipelineOptions {
32
- readonly roots: readonly string[];
84
+ readonly includes: readonly string[];
85
+ readonly excludes?: readonly string[];
33
86
  readonly partials?: string;
34
87
  }
35
88
 
@@ -48,15 +101,22 @@ export interface LintPipelineResult {
48
101
  * @returns Lint results for all discovered prompts.
49
102
  */
50
103
  export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
51
- 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
+ });
52
112
  const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
53
113
  const partialsDirs = resolvePartialsDirs(customPartialsDir);
54
114
 
55
115
  const results = discovered.map((d) => {
56
116
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
57
117
  const raw = readFileSync(d.filePath, "utf8");
58
- const frontmatter = parseFrontmatter(raw, d.filePath);
59
- const template = flattenPartials(clean(raw), partialsDirs);
118
+ const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
119
+ const template = flattenPartials({ template: clean(raw), partialsDirs });
60
120
  const templateVars = extractVariables(template);
61
121
  return lintPrompt(frontmatter.name, d.filePath, frontmatter.schema, templateVars);
62
122
  });
@@ -68,9 +128,11 @@ export function runLintPipeline(options: LintPipelineOptions): LintPipelineResul
68
128
  * Options for the prompts generate pipeline.
69
129
  */
70
130
  export interface GeneratePipelineOptions {
71
- readonly roots: readonly string[];
131
+ readonly includes: readonly string[];
132
+ readonly excludes?: readonly string[];
72
133
  readonly out: string;
73
134
  readonly partials?: string;
135
+ readonly groups?: readonly PromptGroup[];
74
136
  }
75
137
 
76
138
  /**
@@ -91,22 +153,33 @@ export interface GeneratePipelineResult {
91
153
  * @returns Parsed prompts ready for code generation, along with lint results.
92
154
  */
93
155
  export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
94
- 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
+ });
95
164
  const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
96
165
  const partialsDirs = resolvePartialsDirs(customPartialsDir);
166
+ const configGroups = options.groups ?? [];
97
167
 
98
168
  const processed = discovered.map((d) => {
99
169
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
100
170
  const raw = readFileSync(d.filePath, "utf8");
101
- const frontmatter = parseFrontmatter(raw, d.filePath);
102
- const template = flattenPartials(clean(raw), partialsDirs);
171
+ const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
172
+ const template = flattenPartials({ template: clean(raw), partialsDirs });
103
173
  const templateVars = extractVariables(template);
104
174
 
175
+ // Frontmatter group wins; fall back to config-defined group patterns
176
+ const group = frontmatter.group ?? resolveGroupFromConfig(d.filePath, configGroups);
177
+
105
178
  return {
106
179
  lintResult: lintPrompt(frontmatter.name, d.filePath, frontmatter.schema, templateVars),
107
180
  prompt: {
108
181
  name: frontmatter.name,
109
- group: frontmatter.group,
182
+ group,
110
183
  schema: frontmatter.schema,
111
184
  template,
112
185
  sourcePath: d.filePath,
@@ -114,9 +187,12 @@ export function runGeneratePipeline(options: GeneratePipelineOptions): GenerateP
114
187
  };
115
188
  });
116
189
 
190
+ const prompts = processed.map((p) => p.prompt);
191
+ validateUniqueness(prompts);
192
+
117
193
  return {
118
194
  discovered: discovered.length,
119
195
  lintResults: processed.map((p) => p.lintResult),
120
- prompts: processed.map((p) => p.prompt),
196
+ prompts,
121
197
  };
122
198
  }