@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.
@@ -1,12 +1,159 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import type { Context } from "@kidd-cli/core";
1
5
  import { command } from "@kidd-cli/core";
6
+ import { match } from "ts-pattern";
7
+
8
+ import { setupPrompts } from "@/commands/prompts/setup.js";
9
+
10
+ /** @private */
11
+ const CONFIG_TEMPLATE_AGENTS_ONLY = `import { defineConfig } from "@funkai/config";
12
+
13
+ export default defineConfig({
14
+ agents: {},
15
+ });
16
+ `;
2
17
 
3
18
  export default command({
4
19
  description: "Set up your project for the funkai SDK",
5
20
  async handler(ctx) {
6
21
  ctx.logger.intro("funkai — Project Setup");
7
- ctx.logger.info("Run domain-specific setup commands:");
8
- ctx.logger.step("funkai prompts setup — Configure IDE and project for .prompt files");
9
- ctx.logger.step("funkai agents setup — (coming soon)");
10
- ctx.logger.outro("Choose the setup command for your domain.");
22
+
23
+ // --- Domain selection ---
24
+ const domains = await ctx.prompts.multiselect({
25
+ message: "Which domains do you want to set up?",
26
+ options: [
27
+ {
28
+ value: "prompts" as const,
29
+ label: "Prompts",
30
+ hint: "LiquidJS templating, codegen, IDE integration",
31
+ },
32
+ { value: "agents" as const, label: "Agents", hint: "Agent scaffolding and configuration" },
33
+ ],
34
+ initialValues: ["prompts" as const],
35
+ required: true,
36
+ });
37
+
38
+ const hasPrompts = domains.includes("prompts");
39
+ const hasAgents = domains.includes("agents");
40
+
41
+ // --- Create funkai.config.ts ---
42
+ const shouldCreateConfig = await ctx.prompts.confirm({
43
+ message: "Create funkai.config.ts?",
44
+ initialValue: true,
45
+ });
46
+
47
+ if (shouldCreateConfig) {
48
+ const { includes, out } = await resolvePromptSettings(ctx, hasPrompts);
49
+
50
+ const template = buildConfigTemplate({ hasPrompts, hasAgents, includes, out });
51
+ const configPath = resolve("funkai.config.ts");
52
+ // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing config file to project root
53
+ writeFileSync(configPath, template, "utf8");
54
+ ctx.logger.success(`Created ${configPath}`);
55
+ }
56
+
57
+ // --- Run domain-specific setup ---
58
+ if (hasPrompts) {
59
+ ctx.logger.info("");
60
+ ctx.logger.info("Configuring Prompts...");
61
+ await setupPrompts(ctx);
62
+ }
63
+
64
+ if (hasAgents) {
65
+ ctx.logger.info("");
66
+ ctx.logger.info("Agents configuration is not yet available.");
67
+ ctx.logger.info("The agents section has been added to your config for future use.");
68
+ }
69
+
70
+ ctx.logger.outro("Project setup complete.");
11
71
  },
12
72
  });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Private
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** @private */
79
+ interface PromptSettings {
80
+ readonly includes: readonly string[];
81
+ readonly out: string;
82
+ }
83
+
84
+ /**
85
+ * Gather prompt include patterns and output directory from the user.
86
+ *
87
+ * @private
88
+ * @param ctx - CLI context for prompts.
89
+ * @param hasPrompts - Whether the prompts domain is selected.
90
+ * @returns The resolved prompt settings.
91
+ */
92
+ async function resolvePromptSettings(ctx: Context, hasPrompts: boolean): Promise<PromptSettings> {
93
+ if (!hasPrompts) {
94
+ return { includes: ["src/prompts/**"], out: ".prompts/client" };
95
+ }
96
+
97
+ const includesInput = await ctx.prompts.text({
98
+ message: "Prompt include patterns (comma-separated)",
99
+ defaultValue: "src/prompts/**",
100
+ placeholder: "src/prompts/**",
101
+ });
102
+
103
+ const out = await ctx.prompts.text({
104
+ message: "Output directory for generated prompt modules",
105
+ defaultValue: ".prompts/client",
106
+ placeholder: ".prompts/client",
107
+ });
108
+
109
+ return {
110
+ includes: includesInput.split(",").map((r) => r.trim()),
111
+ out,
112
+ };
113
+ }
114
+
115
+ /** @private */
116
+ interface ConfigTemplateOptions {
117
+ readonly hasPrompts: boolean;
118
+ readonly hasAgents: boolean;
119
+ readonly includes: readonly string[];
120
+ readonly out: string;
121
+ }
122
+
123
+ /** @private */
124
+ function buildConfigTemplate({
125
+ hasPrompts,
126
+ hasAgents,
127
+ includes,
128
+ out,
129
+ }: ConfigTemplateOptions): string {
130
+ if (hasPrompts && hasAgents) {
131
+ return buildCustomTemplate(includes, out, true);
132
+ }
133
+ if (hasPrompts) {
134
+ return buildCustomTemplate(includes, out, false);
135
+ }
136
+ return CONFIG_TEMPLATE_AGENTS_ONLY;
137
+ }
138
+
139
+ /** @private */
140
+ function buildCustomTemplate(
141
+ includes: readonly string[],
142
+ out: string,
143
+ includeAgents: boolean,
144
+ ): string {
145
+ const includesStr = includes.map((r) => `"${r}"`).join(", ");
146
+ const agentsBlock = match(includeAgents)
147
+ .with(true, () => "\n agents: {},\n")
148
+ .with(false, () => "\n")
149
+ .exhaustive();
150
+
151
+ return `import { defineConfig } from "@funkai/config";
152
+
153
+ export default defineConfig({
154
+ prompts: {
155
+ includes: [${includesStr}],
156
+ out: "${out}",
157
+ },${agentsBlock}});
158
+ `;
159
+ }
@@ -1,24 +1,42 @@
1
1
  import { command } from "@kidd-cli/core";
2
2
 
3
3
  import { handleLint, lintArgs } from "./prompts/lint.js";
4
+ import { getConfig } from "@/config.js";
4
5
 
5
6
  export default command({
6
7
  description: "Run all validations across the funkai SDK",
7
8
  options: lintArgs,
8
9
  handler(ctx) {
9
10
  const { silent } = ctx.args;
11
+ const config = getConfig(ctx);
10
12
 
11
13
  // --- Prompts validation ---
12
14
  if (!silent) {
13
15
  ctx.logger.info("Running prompts validation...");
14
16
  }
15
17
 
16
- handleLint({ args: ctx.args, logger: ctx.logger, fail: ctx.fail });
18
+ const lintHandleArgs: {
19
+ silent: boolean;
20
+ includes?: readonly string[];
21
+ partials?: string;
22
+ } = { silent: ctx.args.silent };
23
+ if (ctx.args.includes !== undefined) {
24
+ lintHandleArgs.includes = ctx.args.includes;
25
+ }
26
+ if (ctx.args.partials !== undefined) {
27
+ lintHandleArgs.partials = ctx.args.partials;
28
+ }
29
+ handleLint({
30
+ args: lintHandleArgs,
31
+ config: config.prompts,
32
+ logger: ctx.logger,
33
+ fail: ctx.fail,
34
+ });
17
35
 
18
36
  // --- Future: agents validation ---
19
37
 
20
38
  if (!silent) {
21
- ctx.logger.success("All validations passed.");
39
+ ctx.logger.success("No errors found.");
22
40
  }
23
41
  },
24
42
  });
package/src/config.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { FunkaiConfig } from "@funkai/config";
2
+ import { configSchema } from "@funkai/config";
3
+ import type { ConfigType, Context } from "@kidd-cli/core";
4
+
5
+ export { configSchema };
6
+
7
+ /**
8
+ * Extract the typed funkai config from a command context.
9
+ *
10
+ * kidd-cli's `Merge<CliConfig, TConfig>` erases augmented keys when
11
+ * `TConfig` defaults to `Record<string, unknown>`, so we cast here.
12
+ *
13
+ * @param ctx - The CLI context.
14
+ * @returns The typed funkai configuration.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const config = getConfig(ctx);
19
+ * const promptsConfig = config.prompts;
20
+ * ```
21
+ */
22
+ export function getConfig(ctx: Pick<Context, "config">): Readonly<FunkaiConfig> {
23
+ return ctx.config as unknown as Readonly<FunkaiConfig>;
24
+ }
25
+
26
+ declare module "@kidd-cli/core" {
27
+ interface CliConfig extends ConfigType<typeof configSchema> {}
28
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import promptsLint from "@/commands/prompts/lint.js";
10
10
  import promptsSetup from "@/commands/prompts/setup.js";
11
11
  import setup from "@/commands/setup.js";
12
12
  import validate from "@/commands/validate.js";
13
+ import { configSchema } from "@/config.js";
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const packageJson = require("../package.json") as { readonly version: string };
@@ -18,6 +19,9 @@ await cli({
18
19
  description: "CLI for the funkai AI SDK framework",
19
20
  name: "funkai",
20
21
  version: packageJson.version,
22
+ config: {
23
+ schema: configSchema,
24
+ },
21
25
  commands: {
22
26
  generate,
23
27
  setup,
@@ -4,48 +4,60 @@ import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
4
4
 
5
5
  describe(lintPrompt, () => {
6
6
  it("returns no diagnostics when vars match schema", () => {
7
- const result = lintPrompt(
8
- "test",
9
- "test.prompt",
10
- [{ name: "scope", type: "string", required: true }],
11
- ["scope"],
12
- );
7
+ const result = lintPrompt({
8
+ name: "test",
9
+ filePath: "test.prompt",
10
+ schemaVars: [{ name: "scope", type: "string", required: true }],
11
+ templateVars: ["scope"],
12
+ });
13
13
  expect(result.diagnostics).toEqual([]);
14
14
  });
15
15
 
16
16
  it("errors on undefined template variable", () => {
17
- const result = lintPrompt("test", "test.prompt", [], ["scope"]);
17
+ const result = lintPrompt({
18
+ name: "test",
19
+ filePath: "test.prompt",
20
+ schemaVars: [],
21
+ templateVars: ["scope"],
22
+ });
18
23
  expect(result.diagnostics).toHaveLength(1);
19
- expect(result.diagnostics[0].level).toBe("error");
20
- expect(result.diagnostics[0].message).toContain('Undefined variable "scope"');
24
+ const [diag0] = result.diagnostics;
25
+ expect(diag0?.level).toBe("error");
26
+ expect(diag0?.message).toContain('Undefined variable "scope"');
21
27
  });
22
28
 
23
29
  it("warns on unused schema variable", () => {
24
- const result = lintPrompt(
25
- "test",
26
- "test.prompt",
27
- [{ name: "scope", type: "string", required: true }],
28
- [],
29
- );
30
+ const result = lintPrompt({
31
+ name: "test",
32
+ filePath: "test.prompt",
33
+ schemaVars: [{ name: "scope", type: "string", required: true }],
34
+ templateVars: [],
35
+ });
30
36
  expect(result.diagnostics).toHaveLength(1);
31
- expect(result.diagnostics[0].level).toBe("warn");
32
- expect(result.diagnostics[0].message).toContain('Unused variable "scope"');
37
+ const [diag0] = result.diagnostics;
38
+ expect(diag0?.level).toBe("warn");
39
+ expect(diag0?.message).toContain('Unused variable "scope"');
33
40
  });
34
41
 
35
42
  it("reports both errors and warnings", () => {
36
- const result = lintPrompt(
37
- "test",
38
- "test.prompt",
39
- [{ name: "declared", type: "string", required: true }],
40
- ["undeclared"],
41
- );
43
+ const result = lintPrompt({
44
+ name: "test",
45
+ filePath: "test.prompt",
46
+ schemaVars: [{ name: "declared", type: "string", required: true }],
47
+ templateVars: ["undeclared"],
48
+ });
42
49
  expect(result.diagnostics).toHaveLength(2);
43
50
  const levels = result.diagnostics.map((d) => d.level).toSorted();
44
51
  expect(levels).toEqual(["error", "warn"]);
45
52
  });
46
53
 
47
54
  it("returns no diagnostics for prompts with no schema and no vars", () => {
48
- const result = lintPrompt("test", "test.prompt", [], []);
55
+ const result = lintPrompt({
56
+ name: "test",
57
+ filePath: "test.prompt",
58
+ schemaVars: [],
59
+ templateVars: [],
60
+ });
49
61
  expect(result.diagnostics).toEqual([]);
50
62
  });
51
63
  });
@@ -67,6 +67,14 @@ function formatGroupValue(group: string | undefined): string {
67
67
  return "undefined";
68
68
  }
69
69
 
70
+ /** @private */
71
+ function formatGroupJsdoc(group: string | undefined): readonly string[] {
72
+ if (group) {
73
+ return [" *", ` * @group ${group}`];
74
+ }
75
+ return [];
76
+ }
77
+
70
78
  /** @private */
71
79
  function parseGroupSegments(group: string | undefined): readonly string[] {
72
80
  if (group) {
@@ -98,34 +106,78 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
98
106
  return `z.object({\n${fields}\n})`;
99
107
  }
100
108
 
101
- const HEADER = [
102
- "/*",
103
- "|==========================================================================",
104
- "| AUTO-GENERATED DO NOT EDIT",
105
- "|==========================================================================",
106
- "|",
107
- "| Run `funkai prompts generate` to regenerate.",
108
- "|",
109
- "*/",
110
- ].join("\n");
109
+ /** @private */
110
+ function formatHeader(sourcePath?: string): string {
111
+ const sourceLine = match(sourcePath)
112
+ .with(undefined, () => "")
113
+ .otherwise((p) => `// Source: ${p}\n`);
114
+ return [
115
+ "// ─── AUTO-GENERATED ────────────────────────────────────────",
116
+ `${sourceLine}// Regenerate: funkai prompts generate`,
117
+ "// ───────────────────────────────────────────────────────────",
118
+ ].join("\n");
119
+ }
120
+
121
+ /**
122
+ * Derive a unique file slug from group + name.
123
+ *
124
+ * Ungrouped prompts use the name alone. Grouped prompts
125
+ * join group segments and name with hyphens.
126
+ *
127
+ * @param name - The prompt name (kebab-case).
128
+ * @param group - Optional group path (e.g., 'core/agent').
129
+ * @returns The file slug string.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * toFileSlug('system', 'core/agent') // => 'core-agent-system'
134
+ * toFileSlug('greeting', undefined) // => 'greeting'
135
+ * ```
136
+ */
137
+ export function toFileSlug(name: string, group?: string): string {
138
+ if (group) {
139
+ return `${group.replaceAll("/", "-")}-${name}`;
140
+ }
141
+ return name;
142
+ }
143
+
144
+ /**
145
+ * Derive a unique import name (camelCase) from group + name.
146
+ *
147
+ * @param name - The prompt name (kebab-case).
148
+ * @param group - Optional group path (e.g., 'core/agent').
149
+ * @returns The camelCase import identifier.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * toImportName('system', 'core/agent') // => 'coreAgentSystem'
154
+ * toImportName('greeting', undefined) // => 'greeting'
155
+ * ```
156
+ */
157
+ export function toImportName(name: string, group?: string): string {
158
+ return toCamelCase(toFileSlug(name, group));
159
+ }
111
160
 
112
161
  /**
113
162
  * Generate a per-prompt TypeScript module with a default export.
114
163
  *
115
- * The module contains the Zod schema, inlined template, and
116
- * `render` / `validate` functions.
164
+ * The module uses `createPrompt` from `@funkai/prompts` to
165
+ * encapsulate the Zod schema, inlined template, and render logic.
166
+ *
167
+ * @param prompt - The parsed prompt configuration.
168
+ * @returns The generated TypeScript module source code.
117
169
  */
118
170
  export function generatePromptModule(prompt: ParsedPrompt): string {
119
171
  const escaped = escapeTemplateLiteral(prompt.template);
120
172
  const schemaExpr = generateSchemaExpression(prompt.schema);
121
173
  const groupValue = formatGroupValue(prompt.group);
174
+ const header = formatHeader(prompt.sourcePath);
122
175
 
123
176
  const lines: readonly string[] = [
124
- HEADER,
125
- `// Source: ${prompt.sourcePath}`,
177
+ header,
126
178
  "",
127
179
  "import { z } from 'zod'",
128
- "import { liquidEngine } from '@funkai/prompts/runtime'",
180
+ "import { createPrompt } from '@funkai/prompts'",
129
181
  "",
130
182
  `const schema = ${schemaExpr}`,
131
183
  "",
@@ -133,28 +185,16 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
133
185
  "",
134
186
  `const template = \`${escaped}\``,
135
187
  "",
136
- "export default {",
137
- ` name: '${prompt.name}' as const,`,
188
+ "/**",
189
+ ` * **${prompt.name}** prompt module.`,
190
+ ...formatGroupJsdoc(prompt.group),
191
+ " */",
192
+ "export default createPrompt<Variables>({",
193
+ ` name: '${prompt.name}',`,
138
194
  ` group: ${groupValue},`,
195
+ " template,",
139
196
  " schema,",
140
- ...match(prompt.schema.length)
141
- .with(0, () => [
142
- " render(variables?: undefined): string {",
143
- " return liquidEngine.parseAndRenderSync(template, {})",
144
- " },",
145
- " validate(variables?: undefined): Variables {",
146
- " return schema.parse(variables ?? {})",
147
- " },",
148
- ])
149
- .otherwise(() => [
150
- " render(variables: Variables): string {",
151
- " return liquidEngine.parseAndRenderSync(template, schema.parse(variables))",
152
- " },",
153
- " validate(variables: unknown): Variables {",
154
- " return schema.parse(variables)",
155
- " },",
156
- ]),
157
- "}",
197
+ "})",
158
198
  "",
159
199
  ];
160
200
 
@@ -172,6 +212,9 @@ interface TreeNode {
172
212
  /**
173
213
  * Build a nested tree from sorted prompts, grouped by their `group` field.
174
214
  *
215
+ * Leaf values are the unique import name derived from group+name,
216
+ * so prompts with the same name in different groups do not collide.
217
+ *
175
218
  * @param prompts - Sorted parsed prompts.
176
219
  * @returns A tree where leaves are import names and branches are group namespaces.
177
220
  * @throws If a prompt name collides with a group namespace at the same level.
@@ -180,7 +223,8 @@ interface TreeNode {
180
223
  */
181
224
  function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
182
225
  return prompts.reduce<Record<string, unknown>>((root, prompt) => {
183
- const importName = toCamelCase(prompt.name);
226
+ const leafKey = toCamelCase(prompt.name);
227
+ const importName = toImportName(prompt.name, prompt.group);
184
228
  const segments = parseGroupSegments(prompt.group);
185
229
 
186
230
  const target = segments.reduce<Record<string, unknown>>((current, segment) => {
@@ -196,13 +240,13 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
196
240
  return current[segment] as Record<string, unknown>;
197
241
  }, root);
198
242
 
199
- if (typeof target[importName] === "object" && target[importName] !== null) {
243
+ if (typeof target[leafKey] === "object" && target[leafKey] !== null) {
200
244
  throw new Error(
201
- `Collision: prompt "${importName}" conflicts with existing group namespace "${importName}" at the same level.`,
245
+ `Collision: prompt "${leafKey}" conflicts with existing group namespace "${leafKey}" at the same level.`,
202
246
  );
203
247
  }
204
248
 
205
- target[importName] = importName;
249
+ target[leafKey] = importName;
206
250
  return root;
207
251
  }, {}) as TreeNode;
208
252
  }
@@ -221,7 +265,12 @@ function serializeTree(node: TreeNode, indent: number): readonly string[] {
221
265
 
222
266
  return Object.entries(node).flatMap(([key, value]) =>
223
267
  match(typeof value)
224
- .with("string", () => [`${pad}${key},`])
268
+ .with("string", () => {
269
+ if (key === value) {
270
+ return [`${pad}${key},`];
271
+ }
272
+ return [`${pad}${key}: ${value as string},`];
273
+ })
225
274
  .otherwise(() => [
226
275
  `${pad}${key}: {`,
227
276
  ...serializeTree(value as TreeNode, indent + 1),
@@ -236,19 +285,39 @@ function serializeTree(node: TreeNode, indent: number): readonly string[] {
236
285
  *
237
286
  * Prompts are organized into a nested object structure based on their
238
287
  * `group` field, with each `/`-separated segment becoming a nesting level.
288
+ *
289
+ * @param prompts - Sorted parsed prompts to include in the registry.
290
+ * @returns The generated TypeScript source for the registry index module.
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * const source = generateRegistry([
295
+ * { name: 'system', group: 'core/agent', schema: [], template: '...', sourcePath: 'prompts/system.prompt' },
296
+ * ])
297
+ * writeFileSync('index.ts', source)
298
+ * ```
239
299
  */
240
300
  export function generateRegistry(prompts: readonly ParsedPrompt[]): string {
241
- const sorted = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name));
301
+ const sorted = [...prompts].toSorted((a, b) => {
302
+ const slugA = toFileSlug(a.name, a.group);
303
+ const slugB = toFileSlug(b.name, b.group);
304
+ return slugA.localeCompare(slugB);
305
+ });
242
306
 
243
307
  const imports = sorted
244
- .map((p) => `import ${toCamelCase(p.name)} from './${p.name}.js'`)
308
+ .map((p) => {
309
+ const importName = toImportName(p.name, p.group);
310
+ const fileSlug = toFileSlug(p.name, p.group);
311
+ return `import ${importName} from './${fileSlug}.js'`;
312
+ })
245
313
  .join("\n");
246
314
 
247
315
  const tree = buildTree(sorted);
248
316
  const treeLines = serializeTree(tree, 1);
317
+ const header = formatHeader();
249
318
 
250
319
  const lines: readonly string[] = [
251
- HEADER,
320
+ header,
252
321
  "",
253
322
  "import { createPromptRegistry } from '@funkai/prompts'",
254
323
  imports,
@@ -32,18 +32,19 @@ function parseParamsOrEmpty(raw: string, partialName: string): Record<string, st
32
32
  */
33
33
  function parseParams(raw: string, partialName: string): Record<string, string> {
34
34
  const literalMatches = [...raw.matchAll(LITERAL_PARAM_RE)];
35
- const allParamNames = [...raw.matchAll(/(\w+)\s*:/g)].map((m) => m[1]);
35
+ const allParamNames = [...raw.matchAll(/(\w+)\s*:/g)].map(([, m1]) => m1);
36
36
 
37
37
  return Object.fromEntries(
38
38
  allParamNames.map((name) => {
39
- const literal = literalMatches.find((m) => m[1] === name);
39
+ const literal = literalMatches.find(([, m1]) => m1 === name);
40
40
  if (!literal) {
41
41
  throw new Error(
42
42
  `Cannot flatten {% render '${partialName}' %}: parameter "${name}" uses a variable reference. ` +
43
43
  "Only literal string values are supported at codegen time.",
44
44
  );
45
45
  }
46
- return [name, literal[2]];
46
+ const { 2: literalValue } = literal;
47
+ return [name, literalValue];
47
48
  }),
48
49
  );
49
50
  }
@@ -81,10 +82,14 @@ function renderPartial(engine: Liquid, tag: RenderTag): string {
81
82
  */
82
83
  function parseRenderTags(template: string): RenderTag[] {
83
84
  return [...template.matchAll(RENDER_TAG_RE)].map((m) => {
85
+ const [, partialName] = m;
86
+ if (partialName === undefined) {
87
+ throw new Error("Malformed render tag: missing partial name");
88
+ }
84
89
  const rawParams: string = (m[2] ?? "").trim();
85
- const params = parseParamsOrEmpty(rawParams, m[1]);
90
+ const params = parseParamsOrEmpty(rawParams, partialName);
86
91
 
87
- return { fullMatch: m[0], partialName: m[1], params };
92
+ return { fullMatch: m[0], partialName, params };
88
93
  });
89
94
  }
90
95
 
@@ -76,7 +76,11 @@ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams):
76
76
  throw new Error(`No frontmatter found in ${filePath}`);
77
77
  }
78
78
 
79
- const parsed = parseYamlContent(fmMatch[1], filePath);
79
+ const [, fmContent] = fmMatch;
80
+ if (fmContent === undefined) {
81
+ throw new Error(`No frontmatter content found in ${filePath}`);
82
+ }
83
+ const parsed = parseYamlContent(fmContent, filePath);
80
84
 
81
85
  if (!parsed || typeof parsed !== "object") {
82
86
  throw new Error(`Frontmatter is not a valid object in ${filePath}`);
@@ -94,12 +98,24 @@ export function parseFrontmatter({ content, filePath }: ParseFrontmatterParams):
94
98
  );
95
99
  }
96
100
 
97
- const group = parseGroup(parsed.group, filePath);
98
- const version = parseVersion(parsed.version);
101
+ const group = parseGroup(parsed["group"], filePath);
102
+ const version = parseVersion(parsed["version"]);
99
103
 
100
- const schema = parseSchemaBlock(parsed.schema, filePath);
104
+ const schema = parseSchemaBlock(parsed["schema"], filePath);
101
105
 
102
- return { name, group, version, schema };
106
+ const result: {
107
+ name: string;
108
+ schema: readonly SchemaVariable[];
109
+ group?: string;
110
+ version?: string;
111
+ } = { name, schema };
112
+ if (group !== undefined) {
113
+ result.group = group;
114
+ }
115
+ if (version !== undefined) {
116
+ result.version = version;
117
+ }
118
+ return result;
103
119
  }
104
120
 
105
121
  // ---------------------------------------------------------------------------
@@ -180,9 +196,9 @@ function parseSchemaBlock(raw: unknown, filePath: string): readonly SchemaVariab
180
196
  typeof v === "object" && v !== null && !Array.isArray(v),
181
197
  ),
182
198
  (def) => {
183
- const type = stringOrDefault(def.type, "string");
184
- const required = def.required !== false;
185
- const description = stringOrUndefined(def.description);
199
+ const type = stringOrDefault(def["type"], "string");
200
+ const required = def["required"] !== false;
201
+ const description = stringOrUndefined(def["description"]);
186
202
  return { name: varName, type, required, description };
187
203
  },
188
204
  )