@funkai/cli 0.1.3 → 0.2.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.
@@ -10,11 +10,25 @@ interface RenderTag {
10
10
  params: Record<string, string>;
11
11
  }
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Private
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** @private */
18
+ function parseParamsOrEmpty(raw: string, partialName: string): Record<string, string> {
19
+ if (raw.length > 0) {
20
+ return parseParams(raw, partialName);
21
+ }
22
+ return {};
23
+ }
24
+
13
25
  /**
14
26
  * Parse literal string parameters from a render tag's param string.
15
27
  *
16
28
  * Only supports literal string values (e.g. `role: 'Bot'`).
17
29
  * Throws if a parameter value is a variable reference.
30
+ *
31
+ * @private
18
32
  */
19
33
  function parseParams(raw: string, partialName: string): Record<string, string> {
20
34
  const literalMatches = [...raw.matchAll(LITERAL_PARAM_RE)];
@@ -34,18 +48,56 @@ function parseParams(raw: string, partialName: string): Record<string, string> {
34
48
  );
35
49
  }
36
50
 
51
+ /** @private */
52
+ function errorMessage(error: unknown): string {
53
+ if (error instanceof Error) {
54
+ return error.message;
55
+ }
56
+ return String(error);
57
+ }
58
+
59
+ /**
60
+ * Render a single partial tag via LiquidJS, wrapping errors with context.
61
+ *
62
+ * @private
63
+ */
64
+ function renderPartial(engine: Liquid, tag: RenderTag): string {
65
+ const liquidTag = `{% render '${tag.partialName}' ${Object.entries(tag.params)
66
+ .map(([k, v]) => `${k}: '${v}'`)
67
+ .join(", ")} %}`;
68
+ try {
69
+ return engine.parseAndRenderSync(liquidTag);
70
+ } catch (error) {
71
+ throw new Error(`Failed to render partial '${tag.partialName}': ${errorMessage(error)}`, {
72
+ cause: error,
73
+ });
74
+ }
75
+ }
76
+
37
77
  /**
38
78
  * Find all `{% render %}` tags in a template string.
79
+ *
80
+ * @private
39
81
  */
40
82
  function parseRenderTags(template: string): RenderTag[] {
41
83
  return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
42
- const rawParams = m[2] != null ? m[2].trim() : "";
43
- const params = rawParams.length > 0 ? parseParams(rawParams, m[1]) : {};
84
+ const rawParams: string = (m[2] ?? "").trim();
85
+ const params = parseParamsOrEmpty(rawParams, m[1]);
44
86
 
45
87
  return { fullMatch: m[0], partialName: m[1], params };
46
88
  });
47
89
  }
48
90
 
91
+ /**
92
+ * Parameters for flattening partial render tags.
93
+ */
94
+ export interface FlattenPartialsParams {
95
+ /** Template string (frontmatter already stripped). */
96
+ readonly template: string;
97
+ /** Directories to search for partial `.prompt` files. */
98
+ readonly partialsDirs: readonly string[];
99
+ }
100
+
49
101
  /**
50
102
  * Flatten `{% render %}` partial tags in a template at codegen time.
51
103
  *
@@ -56,30 +108,29 @@ function parseRenderTags(template: string): RenderTag[] {
56
108
  * All other Liquid expressions (`{{ var }}`, `{% if %}`, `{% for %}`)
57
109
  * are preserved for runtime rendering.
58
110
  *
59
- * @param template - Template string (frontmatter already stripped).
60
- * @param partialsDirs - Directories to search for partial `.prompt` files.
111
+ * @param params - Template content and partial directories.
61
112
  * @returns Flattened template with all render tags resolved.
113
+ * @example
114
+ * ```ts
115
+ * flattenPartials({ template: "{% render 'header' %}\nBody", partialsDirs: ["./partials"] });
116
+ * // "Welcome!\nBody"
117
+ * ```
62
118
  */
63
- export function flattenPartials(template: string, partialsDirs: string[]): string {
119
+ export function flattenPartials({ template, partialsDirs }: FlattenPartialsParams): string {
64
120
  const tags = parseRenderTags(template);
65
121
  if (tags.length === 0) {
66
122
  return template;
67
123
  }
68
124
 
69
125
  const engine = new Liquid({
70
- root: partialsDirs,
71
- partials: partialsDirs,
126
+ root: [...partialsDirs],
127
+ partials: [...partialsDirs],
72
128
  extname: ".prompt",
73
129
  });
74
130
 
75
131
  const result = tags.reduce((acc, tag) => {
76
- const rendered = engine.parseAndRenderSync(
77
- `{% render '${tag.partialName}' ${Object.entries(tag.params)
78
- .map(([k, v]) => `${k}: '${v}'`)
79
- .join(", ")} %}`,
80
- );
81
-
82
- return acc.replace(tag.fullMatch, rendered);
132
+ const rendered = renderPartial(engine, tag);
133
+ return acc.replace(tag.fullMatch, () => rendered);
83
134
  }, template);
84
135
 
85
136
  return result;
@@ -1,6 +1,10 @@
1
+ import { match, P } from "ts-pattern";
1
2
  import { parse as parseYaml } from "yaml";
2
3
 
4
+ /** Regex matching YAML frontmatter fenced by `---` delimiters. */
3
5
  export const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
6
+
7
+ /** Regex validating prompt names (lowercase alphanumeric with hyphens). */
4
8
  export const NAME_RE = /^[a-z0-9-]+$/;
5
9
 
6
10
  /**
@@ -16,8 +20,8 @@ export const NAME_RE = /^[a-z0-9-]+$/;
16
20
  function parseYamlContent(yaml: string, filePath: string): Record<string, unknown> {
17
21
  try {
18
22
  return parseYaml(yaml) as Record<string, unknown>;
19
- } catch (err) {
20
- throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${err}`, { cause: err });
23
+ } catch (error) {
24
+ throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${error}`, { cause: error });
21
25
  }
22
26
  }
23
27
 
@@ -41,18 +45,32 @@ export interface ParsedFrontmatter {
41
45
  readonly schema: readonly SchemaVariable[];
42
46
  }
43
47
 
48
+ /**
49
+ * Parameters for parsing frontmatter from a `.prompt` file.
50
+ */
51
+ export interface ParseFrontmatterParams {
52
+ /** Raw file content (including frontmatter fences). */
53
+ readonly content: string;
54
+ /** File path for error messages. */
55
+ readonly filePath: string;
56
+ }
57
+
44
58
  /**
45
59
  * Parse YAML frontmatter from a `.prompt` file's raw content.
46
60
  *
47
61
  * Extracts `name`, `group`, `version`, and `schema` fields.
48
62
  * The `schema` field maps variable names to their type definitions.
49
63
  *
50
- * @param content - Raw file content (including frontmatter fences).
51
- * @param filePath - File path for error messages.
64
+ * @param params - Content and file path to parse.
52
65
  * @returns Parsed frontmatter with schema variables.
53
66
  * @throws If frontmatter is missing, malformed, or has an invalid name.
67
+ * @example
68
+ * ```ts
69
+ * const fm = parseFrontmatter({ content: "---\nname: greeting\n---\nHello!", filePath: "greeting.prompt" });
70
+ * // { name: "greeting", group: undefined, version: undefined, schema: [] }
71
+ * ```
54
72
  */
55
- export function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter {
73
+ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams): ParsedFrontmatter {
56
74
  const fmMatch = content.match(FRONTMATTER_RE);
57
75
  if (!fmMatch) {
58
76
  throw new Error(`No frontmatter found in ${filePath}`);
@@ -64,7 +82,7 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
64
82
  throw new Error(`Frontmatter is not a valid object in ${filePath}`);
65
83
  }
66
84
 
67
- const name = parsed.name;
85
+ const { name } = parsed;
68
86
  if (typeof name !== "string" || name.length === 0) {
69
87
  throw new Error(`Missing or empty "name" in frontmatter: ${filePath}`);
70
88
  }
@@ -76,63 +94,103 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
76
94
  );
77
95
  }
78
96
 
79
- const group =
80
- typeof parsed.group === "string"
81
- ? (() => {
82
- const g = parsed.group as string;
83
- const invalidSegment = g.split("/").find((segment) => !NAME_RE.test(segment));
84
- if (invalidSegment !== undefined) {
85
- throw new Error(
86
- `Invalid group segment "${invalidSegment}" in ${filePath}. ` +
87
- "Group segments must be lowercase alphanumeric with hyphens only.",
88
- );
89
- }
90
- return g;
91
- })()
92
- : undefined;
93
- const version = parsed.version != null ? String(parsed.version) : undefined;
97
+ const group = parseGroup(parsed.group, filePath);
98
+ const version = parseVersion(parsed.version);
94
99
 
95
100
  const schema = parseSchemaBlock(parsed.schema, filePath);
96
101
 
97
102
  return { name, group, version, schema };
98
103
  }
99
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // Private
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** @private */
110
+ function stringOrDefault(value: unknown, fallback: string): string {
111
+ if (typeof value === "string") {
112
+ return value;
113
+ }
114
+ return fallback;
115
+ }
116
+
117
+ /** @private */
118
+ function stringOrUndefined(value: unknown): string | undefined {
119
+ if (typeof value === "string") {
120
+ return value;
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * Validate and extract the `group` field from parsed frontmatter.
127
+ *
128
+ * @private
129
+ */
130
+ function parseGroup(raw: unknown, filePath: string): string | undefined {
131
+ if (typeof raw !== "string") {
132
+ return undefined;
133
+ }
134
+ const invalidSegment = raw.split("/").find((segment) => !NAME_RE.test(segment));
135
+ if (invalidSegment !== undefined) {
136
+ throw new Error(
137
+ `Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
138
+ );
139
+ }
140
+ return raw;
141
+ }
142
+
143
+ /**
144
+ * Extract the `version` field from parsed frontmatter.
145
+ *
146
+ * @private
147
+ */
148
+ function parseVersion(raw: unknown): string | undefined {
149
+ if (raw !== null && raw !== undefined) {
150
+ return String(raw);
151
+ }
152
+ return undefined;
153
+ }
154
+
100
155
  /**
101
156
  * Parse the `schema` block from frontmatter into an array of variable definitions.
102
157
  *
103
158
  * @private
104
159
  */
105
- function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
106
- if (raw == null) {
160
+ function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariable[] {
161
+ if (raw === null || raw === undefined) {
107
162
  return [];
108
163
  }
109
164
 
110
165
  if (typeof raw !== "object" || Array.isArray(raw)) {
111
- throw new Error(
166
+ throw new TypeError(
112
167
  `Invalid "schema" in ${filePath}: expected an object mapping variable names to definitions`,
113
168
  );
114
169
  }
115
170
 
116
171
  const schema = raw as Record<string, unknown>;
117
172
 
118
- return Object.entries(schema).map(([varName, value]): SchemaVariable => {
119
- if (typeof value === "string") {
120
- return { name: varName, type: value, required: true };
121
- }
122
-
123
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
124
- const def = value as Record<string, unknown>;
125
- const type = typeof def.type === "string" ? (def.type as string) : "string";
126
- const required = def.required !== false;
127
- const description =
128
- typeof def.description === "string" ? (def.description as string) : undefined;
129
-
130
- return { name: varName, type, required, description };
131
- }
132
-
133
- throw new Error(
134
- `Invalid schema definition for "${varName}" in ${filePath}. ` +
135
- "Expected a type string or an object with { type, required?, description? }.",
136
- );
137
- });
173
+ return Object.entries(schema).map(
174
+ ([varName, value]): SchemaVariable =>
175
+ match(value)
176
+ .with(P.string, (v) => ({ name: varName, type: v, required: true }))
177
+ .with(
178
+ P.when(
179
+ (v): v is Record<string, unknown> =>
180
+ typeof v === "object" && v !== null && !Array.isArray(v),
181
+ ),
182
+ (def) => {
183
+ const type = stringOrDefault(def.type, "string");
184
+ const required = def.required !== false;
185
+ const description = stringOrUndefined(def.description);
186
+ return { name: varName, type, required, description };
187
+ },
188
+ )
189
+ .otherwise(() => {
190
+ throw new Error(
191
+ `Invalid schema definition for "${varName}" in ${filePath}. ` +
192
+ "Expected a type string or an object with { type, required?, description? }.",
193
+ );
194
+ }),
195
+ );
138
196
  }
@@ -36,34 +36,29 @@ export function lintPrompt(
36
36
  schemaVars: readonly SchemaVariable[],
37
37
  templateVars: readonly string[],
38
38
  ): LintResult {
39
- const diagnostics: LintDiagnostic[] = [];
40
39
  const declared = new Set(schemaVars.map((v) => v.name));
41
40
  const used = new Set(templateVars);
42
41
 
43
- for (const varName of used) {
44
- if (!declared.has(varName)) {
45
- diagnostics.push({
46
- level: "error",
47
- message:
48
- `Undefined variable "${varName}" in ${name}.prompt\n` +
49
- ` Variable "${varName}" is used in the template but not declared in frontmatter schema.\n` +
50
- " Add it to the schema section in the frontmatter.",
51
- });
52
- }
53
- }
42
+ const undeclaredErrors: readonly LintDiagnostic[] = [...used]
43
+ .filter((varName) => !declared.has(varName))
44
+ .map((varName) => ({
45
+ level: "error" as const,
46
+ message:
47
+ `Undefined variable "${varName}" in ${name}.prompt\n` +
48
+ ` Variable "${varName}" is used in the template but not declared in frontmatter schema.\n` +
49
+ " Add it to the schema section in the frontmatter.",
50
+ }));
54
51
 
55
- for (const varName of declared) {
56
- if (!used.has(varName)) {
57
- diagnostics.push({
58
- level: "warn",
59
- message:
60
- `Unused variable "${varName}" in ${name}.prompt\n` +
61
- ` Variable "${varName}" is declared in the schema but never used in the template.`,
62
- });
63
- }
64
- }
52
+ const unusedWarnings: readonly LintDiagnostic[] = [...declared]
53
+ .filter((varName) => !used.has(varName))
54
+ .map((varName) => ({
55
+ level: "warn" as const,
56
+ message:
57
+ `Unused variable "${varName}" in ${name}.prompt\n` +
58
+ ` Variable "${varName}" is declared in the schema but never used in the template.`,
59
+ }));
65
60
 
66
- return { name, filePath, diagnostics };
61
+ return { name, filePath, diagnostics: [...undeclaredErrors, ...unusedWarnings] };
67
62
  }
68
63
 
69
64
  /**
@@ -1,35 +1,45 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, extname, join, resolve } from "node:path";
3
3
 
4
+ import { parse as parseYaml } from "yaml";
5
+
4
6
  import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
5
7
 
6
8
  const MAX_DEPTH = 5;
7
9
  const PROMPT_EXT = ".prompt";
8
10
 
11
+ /** A `.prompt` file discovered during directory scanning. */
9
12
  export interface DiscoveredPrompt {
10
13
  readonly name: string;
11
14
  readonly filePath: string;
12
15
  }
13
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // Private
19
+ // ---------------------------------------------------------------------------
20
+
14
21
  /**
15
22
  * Extract the `name` field from YAML frontmatter.
16
23
  *
17
- * This is a lightweight extraction that avoids pulling in a full YAML parser.
18
- * It looks for `name: <value>` in the frontmatter block.
24
+ * Uses a proper YAML parser to handle quoted values and edge cases.
25
+ *
26
+ * @private
19
27
  */
20
28
  function extractName(content: string): string | undefined {
21
- const match = content.match(FRONTMATTER_RE);
22
- if (!match) {
29
+ const fmMatch = content.match(FRONTMATTER_RE);
30
+ if (!fmMatch) {
23
31
  return undefined;
24
32
  }
25
33
 
26
- const frontmatter = match[1];
27
- const nameLine = frontmatter.split("\n").find((line) => line.startsWith("name:"));
28
- if (!nameLine) {
34
+ 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;
38
+ }
39
+ return undefined;
40
+ } catch {
29
41
  return undefined;
30
42
  }
31
-
32
- return nameLine.slice("name:".length).trim();
33
43
  }
34
44
 
35
45
  /**
@@ -37,6 +47,8 @@ function extractName(content: string): string | undefined {
37
47
  *
38
48
  * If the file is named `prompt.prompt`, uses the parent directory name.
39
49
  * Otherwise uses the file stem (e.g. `my-agent.prompt` -> `my-agent`).
50
+ *
51
+ * @private
40
52
  */
41
53
  function deriveNameFromPath(filePath: string): string {
42
54
  const stem = basename(filePath, PROMPT_EXT);
@@ -48,6 +60,8 @@ function deriveNameFromPath(filePath: string): string {
48
60
 
49
61
  /**
50
62
  * Recursively scan a directory for `.prompt` files.
63
+ *
64
+ * @private
51
65
  */
52
66
  function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
53
67
  if (depth > MAX_DEPTH) {
@@ -74,7 +88,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
74
88
 
75
89
  if (entry.isFile() && extname(entry.name) === PROMPT_EXT) {
76
90
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading prompt file content for name extraction
77
- const content = readFileSync(fullPath, "utf-8");
91
+ const content = readFileSync(fullPath, "utf8");
78
92
  const name = extractName(content) ?? deriveNameFromPath(fullPath);
79
93
 
80
94
  if (!NAME_RE.test(name)) {
@@ -102,7 +116,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
102
116
  * @returns Sorted, deduplicated list of discovered prompts.
103
117
  * @throws If duplicate prompt names are found across roots.
104
118
  */
105
- export function discoverPrompts(roots: readonly string[]): DiscoveredPrompt[] {
119
+ export function discoverPrompts(roots: readonly string[]): readonly DiscoveredPrompt[] {
106
120
  const all = roots.flatMap((root) => scanDirectory(resolve(root), 0));
107
121
 
108
122
  const byName = Map.groupBy(all, (prompt) => prompt.name);
@@ -3,13 +3,29 @@ import { resolve } from "node:path";
3
3
 
4
4
  import { clean, PARTIALS_DIR } from "@funkai/prompts/cli";
5
5
 
6
- import { type ParsedPrompt } from "./codegen.js";
6
+ import type { ParsedPrompt } from "./codegen.js";
7
7
  import { extractVariables } from "./extract-variables.js";
8
8
  import { flattenPartials } from "./flatten.js";
9
9
  import { parseFrontmatter } from "./frontmatter.js";
10
- import { lintPrompt, type LintResult } from "./lint.js";
10
+ import { lintPrompt } from "./lint.js";
11
+ import type { LintResult } from "./lint.js";
11
12
  import { discoverPrompts } from "./paths.js";
12
13
 
14
+ /**
15
+ * Resolve the list of partial directories to search.
16
+ *
17
+ * @private
18
+ * @param customDir - Custom partials directory path.
19
+ * @returns Array of directories to search for partials.
20
+ */
21
+ // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
22
+ function resolvePartialsDirs(customDir: string): readonly string[] {
23
+ if (existsSync(customDir)) {
24
+ return [customDir, PARTIALS_DIR];
25
+ }
26
+ return [PARTIALS_DIR];
27
+ }
28
+
13
29
  /**
14
30
  * Options for the prompts lint pipeline.
15
31
  */
@@ -35,16 +51,13 @@ export interface LintPipelineResult {
35
51
  export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
36
52
  const discovered = discoverPrompts([...options.roots]);
37
53
  const customPartialsDir = resolve(options.partials ?? ".prompts/partials");
38
- // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
39
- const partialsDirs = existsSync(customPartialsDir)
40
- ? [customPartialsDir, PARTIALS_DIR]
41
- : [PARTIALS_DIR];
54
+ const partialsDirs = resolvePartialsDirs(customPartialsDir);
42
55
 
43
56
  const results = discovered.map((d) => {
44
57
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
45
- const raw = readFileSync(d.filePath, "utf-8");
46
- const frontmatter = parseFrontmatter(raw, d.filePath);
47
- const template = flattenPartials(clean(raw), partialsDirs);
58
+ const raw = readFileSync(d.filePath, "utf8");
59
+ const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
60
+ const template = flattenPartials({ template: clean(raw), partialsDirs });
48
61
  const templateVars = extractVariables(template);
49
62
  return lintPrompt(frontmatter.name, d.filePath, frontmatter.schema, templateVars);
50
63
  });
@@ -81,16 +94,13 @@ export interface GeneratePipelineResult {
81
94
  export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
82
95
  const discovered = discoverPrompts([...options.roots]);
83
96
  const customPartialsDir = resolve(options.partials ?? resolve(options.out, "../partials"));
84
- // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
85
- const partialsDirs = existsSync(customPartialsDir)
86
- ? [customPartialsDir, PARTIALS_DIR]
87
- : [PARTIALS_DIR];
97
+ const partialsDirs = resolvePartialsDirs(customPartialsDir);
88
98
 
89
99
  const processed = discovered.map((d) => {
90
100
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
91
- const raw = readFileSync(d.filePath, "utf-8");
92
- const frontmatter = parseFrontmatter(raw, d.filePath);
93
- const template = flattenPartials(clean(raw), partialsDirs);
101
+ const raw = readFileSync(d.filePath, "utf8");
102
+ const frontmatter = parseFrontmatter({ content: raw, filePath: d.filePath });
103
+ const template = flattenPartials({ template: clean(raw), partialsDirs });
94
104
  const templateVars = extractVariables(template);
95
105
 
96
106
  return {