@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.
@@ -2,6 +2,17 @@ import { Liquid } from "liquidjs";
2
2
 
3
3
  const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
4
4
 
5
+ /** Module-level engine — no config needed for variable extraction. */
6
+ const engine = new Liquid();
7
+
8
+ /** @private */
9
+ function extractRoot(variable: unknown): string {
10
+ if (Array.isArray(variable)) {
11
+ return String(variable[0]);
12
+ }
13
+ return String(variable);
14
+ }
15
+
5
16
  /**
6
17
  * Extract top-level variable names from a Liquid template string.
7
18
  *
@@ -9,22 +20,22 @@ const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
9
20
  * and extract all referenced variable names. Only returns the root
10
21
  * variable name (e.g. `user` from `{{ user.name }}`).
11
22
  *
23
+ * @param template - The Liquid template string to parse.
24
+ * @returns Sorted, deduplicated array of top-level variable names.
12
25
  * @throws {Error} If a variable name is dangerous (e.g. `__proto__`)
26
+ * @example
27
+ * ```ts
28
+ * extractVariables("Hello {{ user.name }}, you have {{ count }} items");
29
+ * // ["count", "user"]
30
+ * ```
13
31
  */
14
- export function extractVariables(template: string): string[] {
15
- const engine = new Liquid();
32
+ export function extractVariables(template: string): readonly string[] {
16
33
  const parsed = engine.parse(template);
17
34
  const variables = engine.variablesSync(parsed);
18
35
 
19
36
  const roots = new Set(
20
37
  variables.map((variable) => {
21
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
22
- const root: string = (() => {
23
- if (Array.isArray(variable)) {
24
- return String(variable[0]);
25
- }
26
- return String(variable);
27
- })();
38
+ const root = extractRoot(variable);
28
39
 
29
40
  if (DANGEROUS_NAMES.has(root)) {
30
41
  throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
@@ -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,23 +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
84
  const rawParams: string = (m[2] ?? "").trim();
43
- const params: Record<string, string> = (() => {
44
- if (rawParams.length > 0) {
45
- return parseParams(rawParams, m[1]);
46
- }
47
- return {};
48
- })();
85
+ const params = parseParamsOrEmpty(rawParams, m[1]);
49
86
 
50
87
  return { fullMatch: m[0], partialName: m[1], params };
51
88
  });
52
89
  }
53
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
+
54
101
  /**
55
102
  * Flatten `{% render %}` partial tags in a template at codegen time.
56
103
  *
@@ -61,11 +108,15 @@ function parseRenderTags(template: string): RenderTag[] {
61
108
  * All other Liquid expressions (`{{ var }}`, `{% if %}`, `{% for %}`)
62
109
  * are preserved for runtime rendering.
63
110
  *
64
- * @param template - Template string (frontmatter already stripped).
65
- * @param partialsDirs - Directories to search for partial `.prompt` files.
111
+ * @param params - Template content and partial directories.
66
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
+ * ```
67
118
  */
68
- export function flattenPartials(template: string, partialsDirs: readonly string[]): string {
119
+ export function flattenPartials({ template, partialsDirs }: FlattenPartialsParams): string {
69
120
  const tags = parseRenderTags(template);
70
121
  if (tags.length === 0) {
71
122
  return template;
@@ -78,13 +129,8 @@ export function flattenPartials(template: string, partialsDirs: readonly string[
78
129
  });
79
130
 
80
131
  const result = tags.reduce((acc, tag) => {
81
- const rendered = engine.parseAndRenderSync(
82
- `{% render '${tag.partialName}' ${Object.entries(tag.params)
83
- .map(([k, v]) => `${k}: '${v}'`)
84
- .join(", ")} %}`,
85
- );
86
-
87
- return acc.replace(tag.fullMatch, rendered);
132
+ const rendered = renderPartial(engine, tag);
133
+ return acc.replace(tag.fullMatch, () => rendered);
88
134
  }, template);
89
135
 
90
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
  /**
@@ -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}`);
@@ -76,37 +94,70 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
76
94
  );
77
95
  }
78
96
 
79
- const group: string | undefined = (() => {
80
- if (typeof parsed.group === "string") {
81
- const g = parsed.group as string;
82
- const invalidSegment = g.split("/").find((segment) => !NAME_RE.test(segment));
83
- if (invalidSegment !== undefined) {
84
- throw new Error(
85
- `Invalid group segment "${invalidSegment}" in ${filePath}. Group segments must be lowercase alphanumeric with hyphens only.`,
86
- );
87
- }
88
- return g;
89
- }
90
- return undefined;
91
- })();
92
- const version: string | undefined = (() => {
93
- if (parsed.version !== null && parsed.version !== undefined) {
94
- return String(parsed.version);
95
- }
96
- return undefined;
97
- })();
97
+ const group = parseGroup(parsed.group, filePath);
98
+ const version = parseVersion(parsed.version);
98
99
 
99
100
  const schema = parseSchemaBlock(parsed.schema, filePath);
100
101
 
101
102
  return { name, group, version, schema };
102
103
  }
103
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
+
104
155
  /**
105
156
  * Parse the `schema` block from frontmatter into an array of variable definitions.
106
157
  *
107
158
  * @private
108
159
  */
109
- function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
160
+ function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariable[] {
110
161
  if (raw === null || raw === undefined) {
111
162
  return [];
112
163
  }
@@ -119,34 +170,27 @@ function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
119
170
 
120
171
  const schema = raw as Record<string, unknown>;
121
172
 
122
- return Object.entries(schema).map(([varName, value]): SchemaVariable => {
123
- if (typeof value === "string") {
124
- return { name: varName, type: value, required: true };
125
- }
126
-
127
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
128
- const def = value as Record<string, unknown>;
129
- // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
130
- const type: string = (() => {
131
- if (typeof def.type === "string") {
132
- return def.type as string;
133
- }
134
- return "string";
135
- })();
136
- const required = def.required !== false;
137
- const description: string | undefined = (() => {
138
- if (typeof def.description === "string") {
139
- return def.description as string;
140
- }
141
- return undefined;
142
- })();
143
-
144
- return { name: varName, type, required, description };
145
- }
146
-
147
- throw new Error(
148
- `Invalid schema definition for "${varName}" in ${filePath}. ` +
149
- "Expected a type string or an object with { type, required?, description? }.",
150
- );
151
- });
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
+ );
152
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,56 @@
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
+
4
+ import picomatch from "picomatch";
5
+ import { parse as parseYaml } from "yaml";
3
6
 
4
7
  import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
5
8
 
6
9
  const MAX_DEPTH = 5;
7
10
  const PROMPT_EXT = ".prompt";
8
11
 
12
+ /** A `.prompt` file discovered during directory scanning. */
9
13
  export interface DiscoveredPrompt {
10
14
  readonly name: string;
11
15
  readonly filePath: string;
12
16
  }
13
17
 
18
+ /**
19
+ * Options for prompt discovery.
20
+ */
21
+ export interface DiscoverPromptsOptions {
22
+ /** Glob patterns to scan for `.prompt` files (defaults to `['./**']`). */
23
+ readonly includes: readonly string[];
24
+ /** Glob patterns to exclude from discovery. */
25
+ readonly excludes?: readonly string[];
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Private
30
+ // ---------------------------------------------------------------------------
31
+
14
32
  /**
15
33
  * Extract the `name` field from YAML frontmatter.
16
34
  *
17
- * This is a lightweight extraction that avoids pulling in a full YAML parser.
18
- * It looks for `name: <value>` in the frontmatter block.
35
+ * Uses a proper YAML parser to handle quoted values and edge cases.
36
+ *
37
+ * @private
19
38
  */
20
39
  function extractName(content: string): string | undefined {
21
- const match = content.match(FRONTMATTER_RE);
22
- if (!match) {
40
+ const fmMatch = content.match(FRONTMATTER_RE);
41
+ if (!fmMatch) {
23
42
  return undefined;
24
43
  }
25
44
 
26
- const [, frontmatter] = match;
27
- const nameLine = frontmatter.split("\n").find((line) => line.startsWith("name:"));
28
- if (!nameLine) {
45
+ try {
46
+ const parsed = parseYaml(fmMatch[1]) as Record<string, unknown> | null;
47
+ if (parsed !== null && parsed !== undefined && typeof parsed.name === "string") {
48
+ return parsed.name;
49
+ }
50
+ return undefined;
51
+ } catch {
29
52
  return undefined;
30
53
  }
31
-
32
- return nameLine.slice("name:".length).trim();
33
54
  }
34
55
 
35
56
  /**
@@ -37,6 +58,8 @@ function extractName(content: string): string | undefined {
37
58
  *
38
59
  * If the file is named `prompt.prompt`, uses the parent directory name.
39
60
  * Otherwise uses the file stem (e.g. `my-agent.prompt` -> `my-agent`).
61
+ *
62
+ * @private
40
63
  */
41
64
  function deriveNameFromPath(filePath: string): string {
42
65
  const stem = basename(filePath, PROMPT_EXT);
@@ -46,8 +69,38 @@ function deriveNameFromPath(filePath: string): string {
46
69
  return stem;
47
70
  }
48
71
 
72
+ /**
73
+ * Extract the static base directory from a glob pattern.
74
+ *
75
+ * Returns the longest directory prefix before any glob characters
76
+ * (`*`, `?`, `{`, `[`). Falls back to `'.'` if the pattern starts
77
+ * with a glob character.
78
+ *
79
+ * @private
80
+ */
81
+ function extractBaseDir(pattern: string): string {
82
+ const globChars = new Set(["*", "?", "{", "["]);
83
+ const parts = pattern.split("/");
84
+ const staticParts: string[] = [];
85
+
86
+ for (const part of parts) {
87
+ if ([...part].some((ch) => globChars.has(ch))) {
88
+ break;
89
+ }
90
+ staticParts.push(part);
91
+ }
92
+
93
+ if (staticParts.length === 0) {
94
+ return ".";
95
+ }
96
+
97
+ return staticParts.join("/");
98
+ }
99
+
49
100
  /**
50
101
  * Recursively scan a directory for `.prompt` files.
102
+ *
103
+ * @private
51
104
  */
52
105
  function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
53
106
  if (depth > MAX_DEPTH) {
@@ -96,23 +149,36 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
96
149
  }
97
150
 
98
151
  /**
99
- * Discover all `.prompt` files from the given root directories.
152
+ * Discover all `.prompt` files matching the given include/exclude patterns.
153
+ *
154
+ * Extracts base directories from the include patterns, scans them
155
+ * recursively, then filters results through picomatch.
156
+ *
157
+ * Name uniqueness is **not** enforced here — prompts with the same name
158
+ * are allowed as long as they belong to different groups. Uniqueness
159
+ * is validated downstream in the pipeline after frontmatter parsing,
160
+ * where group information is available.
100
161
  *
101
- * @param roots - Directories to scan recursively.
102
- * @returns Sorted, deduplicated list of discovered prompts.
103
- * @throws If duplicate prompt names are found across roots.
162
+ * @param options - Include and exclude glob patterns.
163
+ * @returns Sorted list of discovered prompts.
104
164
  */
105
- export function discoverPrompts(roots: readonly string[]): DiscoveredPrompt[] {
106
- const all = roots.flatMap((root) => scanDirectory(resolve(root), 0));
165
+ export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
166
+ const { includes, excludes = [] } = options;
107
167
 
108
- const byName = Map.groupBy(all, (prompt) => prompt.name);
168
+ const baseDirs = [...new Set(includes.map((pattern) => resolve(extractBaseDir(pattern))))];
169
+ const all = baseDirs.flatMap((dir) => scanDirectory(dir, 0));
109
170
 
110
- const duplicate = [...byName.entries()].find(([, prompts]) => prompts.length > 1);
111
- if (duplicate) {
112
- const [name, prompts] = duplicate;
113
- const paths = prompts.map((p) => p.filePath).join("\n ");
114
- throw new Error(`Duplicate prompt name "${name}" found in:\n ${paths}`);
115
- }
171
+ const isIncluded = picomatch(includes as string[]);
172
+ const isExcluded = picomatch(excludes as string[]);
173
+
174
+ const filtered = all.filter((prompt) => {
175
+ const matchPath = relative(process.cwd(), prompt.filePath).replaceAll("\\", "/");
176
+ return isIncluded(matchPath) && !isExcluded(matchPath);
177
+ });
178
+
179
+ const deduped = [
180
+ ...new Map(filtered.map((prompt) => [prompt.filePath, prompt] as const)).values(),
181
+ ];
116
182
 
117
- return all.toSorted((a, b) => a.name.localeCompare(b.name));
183
+ return deduped.toSorted((a, b) => a.name.localeCompare(b.name));
118
184
  }