@funkai/cli 0.1.3 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funkai/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "CLI for the funkai AI SDK framework",
6
6
  "keywords": [
@@ -25,8 +25,11 @@
25
25
  "funkai": "./bin/funkai.mjs"
26
26
  },
27
27
  "type": "module",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
28
31
  "dependencies": {
29
- "@kidd-cli/core": "^0.8.2",
32
+ "@kidd-cli/core": "^0.10.0",
30
33
  "liquidjs": "^10.25.0",
31
34
  "ts-pattern": "^5.9.0",
32
35
  "yaml": "^2.8.2",
@@ -34,10 +37,10 @@
34
37
  "@funkai/prompts": "0.2.0"
35
38
  },
36
39
  "devDependencies": {
37
- "@kidd-cli/cli": "^0.4.5",
40
+ "@kidd-cli/cli": "^0.4.9",
38
41
  "@types/node": "^25.5.0",
39
42
  "@vitest/coverage-v8": "^4.1.0",
40
- "tsdown": "^0.21.3",
43
+ "tsdown": "^0.21.4",
41
44
  "typescript": "^5.9.3",
42
45
  "vitest": "^4.1.0"
43
46
  },
@@ -5,7 +5,7 @@ import { command } from "@kidd-cli/core";
5
5
  import { match, P } from "ts-pattern";
6
6
  import { z } from "zod";
7
7
 
8
- const TEMPLATE = (name: string) => `---
8
+ const createTemplate = (name: string) => `---
9
9
  name: ${name}
10
10
  ---
11
11
 
@@ -34,7 +34,7 @@ export default command({
34
34
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: directory derived from user CLI path argument
35
35
  mkdirSync(dir, { recursive: true });
36
36
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: file path derived from user CLI path argument
37
- writeFileSync(filePath, TEMPLATE(name), "utf-8");
37
+ writeFileSync(filePath, createTemplate(name), "utf8");
38
38
 
39
39
  ctx.logger.success(`Created ${filePath}`);
40
40
  },
@@ -50,8 +50,13 @@ export function handleGenerate(
50
50
 
51
51
  if (!silent) {
52
52
  for (const prompt of prompts) {
53
- const varList =
54
- prompt.schema.length > 0 ? ` (${prompt.schema.map((v) => v.name).join(", ")})` : "";
53
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
54
+ const varList: string = (() => {
55
+ if (prompt.schema.length > 0) {
56
+ return ` (${prompt.schema.map((v) => v.name).join(", ")})`;
57
+ }
58
+ return "";
59
+ })();
55
60
  logger.step(`${prompt.name}${varList}`);
56
61
  }
57
62
  }
@@ -77,12 +82,12 @@ export function handleGenerate(
77
82
  for (const prompt of prompts) {
78
83
  const content = generatePromptModule(prompt);
79
84
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing generated module to output directory
80
- writeFileSync(resolve(outDir, `${prompt.name}.ts`), content, "utf-8");
85
+ writeFileSync(resolve(outDir, `${prompt.name}.ts`), content, "utf8");
81
86
  }
82
87
 
83
88
  const registryContent = generateRegistry(prompts);
84
89
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing generated registry to output directory
85
- writeFileSync(resolve(outDir, "index.ts"), registryContent, "utf-8");
90
+ writeFileSync(resolve(outDir, "index.ts"), registryContent, "utf8");
86
91
 
87
92
  if (!silent) {
88
93
  logger.success(`Generated ${prompts.length} prompt module(s) + registry → ${outDir}`);
@@ -50,13 +50,14 @@ export function handleLint(
50
50
  const warnCount = diagnostics.filter((d) => d.level !== "error").length;
51
51
 
52
52
  if (!silent) {
53
- const summary = [
54
- `${discovered} prompt(s) linted`,
55
- errorCount > 0 ? `${errorCount} error(s)` : undefined,
56
- warnCount > 0 ? `${warnCount} warning(s)` : undefined,
57
- ]
58
- .filter(Boolean)
59
- .join(", ");
53
+ const summaryParts: string[] = [`${discovered} prompt(s) linted`];
54
+ if (errorCount > 0) {
55
+ summaryParts.push(`${errorCount} error(s)`);
56
+ }
57
+ if (warnCount > 0) {
58
+ summaryParts.push(`${warnCount} warning(s)`);
59
+ }
60
+ const summary = summaryParts.join(", ");
60
61
 
61
62
  logger.info(summary);
62
63
  }
@@ -38,7 +38,7 @@ export default command({
38
38
  "liquid.engine": "standard",
39
39
  };
40
40
 
41
- writeFileSync(settingsPath, JSON.stringify(updatedSettings, null, 2) + "\n", "utf-8");
41
+ writeFileSync(settingsPath, `${JSON.stringify(updatedSettings, null, 2)}\n`, "utf8");
42
42
  ctx.logger.success(`Updated ${settingsPath}`);
43
43
  }
44
44
 
@@ -57,14 +57,19 @@ export default command({
57
57
  const currentRecs = (extensions.recommendations ?? []) as string[];
58
58
  const extensionId = "sissel.shopify-liquid";
59
59
 
60
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
61
+ const recommendations: string[] = (() => {
62
+ if (currentRecs.includes(extensionId)) {
63
+ return currentRecs;
64
+ }
65
+ return [...currentRecs, extensionId];
66
+ })();
60
67
  const updatedExtensions = {
61
68
  ...extensions,
62
- recommendations: currentRecs.includes(extensionId)
63
- ? currentRecs
64
- : [...currentRecs, extensionId],
69
+ recommendations,
65
70
  };
66
71
 
67
- writeFileSync(extensionsPath, JSON.stringify(updatedExtensions, null, 2) + "\n", "utf-8");
72
+ writeFileSync(extensionsPath, `${JSON.stringify(updatedExtensions, null, 2)}\n`, "utf8");
68
73
  ctx.logger.success(`Updated ${extensionsPath}`);
69
74
  }
70
75
 
@@ -75,15 +80,27 @@ export default command({
75
80
 
76
81
  if (shouldGitignore) {
77
82
  const gitignorePath = resolve(GITIGNORE_FILE);
78
- const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
79
-
80
- if (!existing.includes(GITIGNORE_ENTRY)) {
81
- const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
83
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
84
+ const existing: string = (() => {
85
+ if (existsSync(gitignorePath)) {
86
+ return readFileSync(gitignorePath, "utf8");
87
+ }
88
+ return "";
89
+ })();
90
+
91
+ if (existing.includes(GITIGNORE_ENTRY)) {
92
+ ctx.logger.info(`${GITIGNORE_ENTRY} already in ${gitignorePath}`);
93
+ } else {
94
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
95
+ const separator: string = (() => {
96
+ if (existing.length > 0 && !existing.endsWith("\n")) {
97
+ return "\n";
98
+ }
99
+ return "";
100
+ })();
82
101
  const block = `${separator}\n# Generated prompt client (created by \`funkai prompts generate\`)\n${GITIGNORE_ENTRY}\n`;
83
- writeFileSync(gitignorePath, existing + block, "utf-8");
102
+ writeFileSync(gitignorePath, `${existing}${block}`, "utf8");
84
103
  ctx.logger.success(`Added ${GITIGNORE_ENTRY} to ${gitignorePath}`);
85
- } else {
86
- ctx.logger.info(`${GITIGNORE_ENTRY} already in ${gitignorePath}`);
87
104
  }
88
105
  }
89
106
 
@@ -100,7 +117,9 @@ export default command({
100
117
  const existingPaths = (compilerOptions.paths ?? {}) as Record<string, string[]>;
101
118
 
102
119
  // oxlint-disable-next-line security/detect-object-injection -- safe: PROMPTS_ALIAS is a known constant string
103
- if (!existingPaths[PROMPTS_ALIAS]) {
120
+ if (existingPaths[PROMPTS_ALIAS]) {
121
+ ctx.logger.info(`${PROMPTS_ALIAS} alias already in ${tsconfigPath}`);
122
+ } else {
104
123
  const updatedTsconfig = {
105
124
  ...tsconfig,
106
125
  compilerOptions: {
@@ -113,10 +132,8 @@ export default command({
113
132
  },
114
133
  };
115
134
 
116
- writeFileSync(tsconfigPath, JSON.stringify(updatedTsconfig, null, 2) + "\n", "utf-8");
135
+ writeFileSync(tsconfigPath, `${JSON.stringify(updatedTsconfig, null, 2)}\n`, "utf8");
117
136
  ctx.logger.success(`Added ${PROMPTS_ALIAS} alias to ${tsconfigPath}`);
118
- } else {
119
- ctx.logger.info(`${PROMPTS_ALIAS} alias already in ${tsconfigPath}`);
120
137
  }
121
138
  }
122
139
 
@@ -136,7 +153,7 @@ function readJsonFile(filePath: string): Record<string, unknown> {
136
153
 
137
154
  try {
138
155
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading tsconfig file
139
- const content = readFileSync(filePath, "utf-8");
156
+ const content = readFileSync(filePath, "utf8");
140
157
  return JSON.parse(content) as Record<string, unknown>;
141
158
  } catch {
142
159
  return {};
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { extractVariables } from "@/lib/prompts/extract-variables.js";
4
4
 
5
- describe("extractVariables", () => {
5
+ describe(extractVariables, () => {
6
6
  it("extracts simple variables", () => {
7
7
  expect(extractVariables("{{ name }}")).toEqual(["name"]);
8
8
  });
@@ -6,7 +6,7 @@ import { flattenPartials } from "@/lib/prompts/flatten.js";
6
6
 
7
7
  const PARTIALS_DIR = resolve(import.meta.dirname, "../../../../../prompts/src/prompts");
8
8
 
9
- describe("flattenPartials", () => {
9
+ describe(flattenPartials, () => {
10
10
  describe("param parsing", () => {
11
11
  it("resolves a single literal param", () => {
12
12
  const template = "{% render 'identity', role: 'Bot' %}";
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
4
4
 
5
- describe("parseFrontmatter", () => {
5
+ describe(parseFrontmatter, () => {
6
6
  it("parses name from frontmatter", () => {
7
7
  const content = "---\nname: my-prompt\n---\nHello";
8
8
  const result = parseFrontmatter(content, "test.prompt");
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
4
4
 
5
- describe("lintPrompt", () => {
5
+ describe(lintPrompt, () => {
6
6
  it("returns no diagnostics when vars match schema", () => {
7
7
  const result = lintPrompt(
8
8
  "test",
@@ -50,10 +50,10 @@ describe("lintPrompt", () => {
50
50
  });
51
51
  });
52
52
 
53
- describe("hasLintErrors", () => {
53
+ describe(hasLintErrors, () => {
54
54
  it("returns false when no errors", () => {
55
55
  const results = [{ name: "test", filePath: "test.prompt", diagnostics: [] }];
56
- expect(hasLintErrors(results)).toBe(false);
56
+ expect(hasLintErrors(results)).toBeFalsy();
57
57
  });
58
58
 
59
59
  it("returns true when errors exist", () => {
@@ -64,7 +64,7 @@ describe("hasLintErrors", () => {
64
64
  diagnostics: [{ level: "error" as const, message: "oops" }],
65
65
  },
66
66
  ];
67
- expect(hasLintErrors(results)).toBe(true);
67
+ expect(hasLintErrors(results)).toBeTruthy();
68
68
  });
69
69
 
70
70
  it("returns false when only warnings", () => {
@@ -75,6 +75,6 @@ describe("hasLintErrors", () => {
75
75
  diagnostics: [{ level: "warn" as const, message: "hmm" }],
76
76
  },
77
77
  ];
78
- expect(hasLintErrors(results)).toBe(false);
78
+ expect(hasLintErrors(results)).toBeFalsy();
79
79
  });
80
80
  });
@@ -24,7 +24,7 @@ export interface ParsedPrompt {
24
24
  function toPascalCase(name: string): string {
25
25
  return name
26
26
  .split("-")
27
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
27
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
28
28
  .join("");
29
29
  }
30
30
 
@@ -38,7 +38,7 @@ function toPascalCase(name: string): string {
38
38
  */
39
39
  function toCamelCase(name: string): string {
40
40
  const pascal = toPascalCase(name);
41
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
41
+ return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
42
42
  }
43
43
 
44
44
  /**
@@ -49,7 +49,10 @@ function toCamelCase(name: string): string {
49
49
  * @private
50
50
  */
51
51
  function escapeTemplateLiteral(str: string): string {
52
- return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
52
+ return str
53
+ .replaceAll("\\", String.raw`\\`)
54
+ .replaceAll("`", "\\`")
55
+ .replaceAll("${", "\\${");
53
56
  }
54
57
 
55
58
  /**
@@ -65,7 +68,13 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
65
68
  const fields = vars
66
69
  .map((v) => {
67
70
  const base = "z.string()";
68
- const expr = v.required ? base : `${base}.optional()`;
71
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
72
+ const expr: string = (() => {
73
+ if (v.required) {
74
+ return base;
75
+ }
76
+ return `${base}.optional()`;
77
+ })();
69
78
  return ` ${v.name}: ${expr},`;
70
79
  })
71
80
  .join("\n");
@@ -93,7 +102,13 @@ const HEADER = [
93
102
  export function generatePromptModule(prompt: ParsedPrompt): string {
94
103
  const escaped = escapeTemplateLiteral(prompt.template);
95
104
  const schemaExpr = generateSchemaExpression(prompt.schema);
96
- const groupValue = prompt.group != null ? `'${prompt.group}' as const` : "undefined";
105
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
106
+ const groupValue: string = (() => {
107
+ if (prompt.group) {
108
+ return `'${prompt.group}' as const`;
109
+ }
110
+ return "undefined";
111
+ })();
97
112
 
98
113
  const lines: string[] = [
99
114
  HEADER,
@@ -140,9 +155,9 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
140
155
  * A tree node used during registry code generation.
141
156
  * Leaves hold the camelCase import name; branches hold nested nodes.
142
157
  */
143
- type TreeNode = {
158
+ interface TreeNode {
144
159
  readonly [key: string]: string | TreeNode;
145
- };
160
+ }
146
161
 
147
162
  /**
148
163
  * Build a nested tree from sorted prompts, grouped by their `group` field.
@@ -156,17 +171,22 @@ type TreeNode = {
156
171
  function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
157
172
  return prompts.reduce<Record<string, unknown>>((root, prompt) => {
158
173
  const importName = toCamelCase(prompt.name);
159
- const segments = prompt.group ? prompt.group.split("/").map(toCamelCase) : [];
174
+ // oxlint-disable-next-line unicorn/prefer-ternary -- no-ternary rule forbids ternaries
175
+ const segments: string[] = (() => {
176
+ if (prompt.group) {
177
+ return prompt.group.split("/").map(toCamelCase);
178
+ }
179
+ return [];
180
+ })();
160
181
 
161
182
  const target = segments.reduce<Record<string, unknown>>((current, segment) => {
162
183
  const existing = current[segment];
163
184
  if (typeof existing === "string") {
164
- throw new Error(
165
- `Collision: prompt "${existing}" and group namespace "${segment}" ` +
166
- "share the same key at the same level.",
185
+ throw new TypeError(
186
+ `Collision: prompt "${existing}" and group namespace "${segment}" share the same key at the same level.`,
167
187
  );
168
188
  }
169
- if (existing == null) {
189
+ if (existing === null || existing === undefined) {
170
190
  current[segment] = {};
171
191
  }
172
192
  return current[segment] as Record<string, unknown>;
@@ -174,8 +194,7 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
174
194
 
175
195
  if (typeof target[importName] === "object" && target[importName] !== null) {
176
196
  throw new Error(
177
- `Collision: prompt "${importName}" conflicts with existing group namespace ` +
178
- `"${importName}" at the same level.`,
197
+ `Collision: prompt "${importName}" conflicts with existing group namespace "${importName}" at the same level.`,
179
198
  );
180
199
  }
181
200
 
@@ -196,11 +215,19 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
196
215
  function serializeTree(node: TreeNode, indent: number): string[] {
197
216
  const pad = " ".repeat(indent);
198
217
 
199
- return Object.entries(node).flatMap(([key, value]) =>
200
- typeof value === "string"
201
- ? [`${pad}${key},`]
202
- : [`${pad}${key}: {`, ...serializeTree(value, indent + 1), `${pad}},`],
203
- );
218
+ const lines: string[] = [];
219
+ for (const [key, value] of Object.entries(node)) {
220
+ if (typeof value === "string") {
221
+ lines.push(`${pad}${key},`);
222
+ } else {
223
+ lines.push(`${pad}${key}: {`);
224
+ for (const child of serializeTree(value, indent + 1)) {
225
+ lines.push(child);
226
+ }
227
+ lines.push(`${pad}},`);
228
+ }
229
+ }
230
+ return lines;
204
231
  }
205
232
 
206
233
  /**
@@ -18,7 +18,13 @@ export function extractVariables(template: string): string[] {
18
18
 
19
19
  const roots = new Set(
20
20
  variables.map((variable) => {
21
- const root = Array.isArray(variable) ? String(variable[0]) : String(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
+ })();
22
28
 
23
29
  if (DANGEROUS_NAMES.has(root)) {
24
30
  throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);
@@ -39,8 +39,13 @@ function parseParams(raw: string, partialName: string): Record<string, string> {
39
39
  */
40
40
  function parseRenderTags(template: string): RenderTag[] {
41
41
  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]) : {};
42
+ 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
+ })();
44
49
 
45
50
  return { fullMatch: m[0], partialName: m[1], params };
46
51
  });
@@ -60,15 +65,15 @@ function parseRenderTags(template: string): RenderTag[] {
60
65
  * @param partialsDirs - Directories to search for partial `.prompt` files.
61
66
  * @returns Flattened template with all render tags resolved.
62
67
  */
63
- export function flattenPartials(template: string, partialsDirs: string[]): string {
68
+ export function flattenPartials(template: string, partialsDirs: readonly string[]): string {
64
69
  const tags = parseRenderTags(template);
65
70
  if (tags.length === 0) {
66
71
  return template;
67
72
  }
68
73
 
69
74
  const engine = new Liquid({
70
- root: partialsDirs,
71
- partials: partialsDirs,
75
+ root: [...partialsDirs],
76
+ partials: [...partialsDirs],
72
77
  extname: ".prompt",
73
78
  });
74
79
 
@@ -16,8 +16,8 @@ export const NAME_RE = /^[a-z0-9-]+$/;
16
16
  function parseYamlContent(yaml: string, filePath: string): Record<string, unknown> {
17
17
  try {
18
18
  return parseYaml(yaml) as Record<string, unknown>;
19
- } catch (err) {
20
- throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${err}`, { cause: err });
19
+ } catch (error) {
20
+ throw new Error(`Failed to parse YAML frontmatter in ${filePath}: ${error}`, { cause: error });
21
21
  }
22
22
  }
23
23
 
@@ -64,7 +64,7 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
64
64
  throw new Error(`Frontmatter is not a valid object in ${filePath}`);
65
65
  }
66
66
 
67
- const name = parsed.name;
67
+ const { name } = parsed;
68
68
  if (typeof name !== "string" || name.length === 0) {
69
69
  throw new Error(`Missing or empty "name" in frontmatter: ${filePath}`);
70
70
  }
@@ -76,21 +76,25 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
76
76
  );
77
77
  }
78
78
 
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;
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
+ })();
94
98
 
95
99
  const schema = parseSchemaBlock(parsed.schema, filePath);
96
100
 
@@ -103,12 +107,12 @@ export function parseFrontmatter(content: string, filePath: string): ParsedFront
103
107
  * @private
104
108
  */
105
109
  function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
106
- if (raw == null) {
110
+ if (raw === null || raw === undefined) {
107
111
  return [];
108
112
  }
109
113
 
110
114
  if (typeof raw !== "object" || Array.isArray(raw)) {
111
- throw new Error(
115
+ throw new TypeError(
112
116
  `Invalid "schema" in ${filePath}: expected an object mapping variable names to definitions`,
113
117
  );
114
118
  }
@@ -122,10 +126,20 @@ function parseSchemaBlock(raw: unknown, filePath: string): SchemaVariable[] {
122
126
 
123
127
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
124
128
  const def = value as Record<string, unknown>;
125
- const type = typeof def.type === "string" ? (def.type as string) : "string";
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
+ })();
126
136
  const required = def.required !== false;
127
- const description =
128
- typeof def.description === "string" ? (def.description as string) : undefined;
137
+ const description: string | undefined = (() => {
138
+ if (typeof def.description === "string") {
139
+ return def.description as string;
140
+ }
141
+ return undefined;
142
+ })();
129
143
 
130
144
  return { name: varName, type, required, description };
131
145
  }
@@ -23,7 +23,7 @@ function extractName(content: string): string | undefined {
23
23
  return undefined;
24
24
  }
25
25
 
26
- const frontmatter = match[1];
26
+ const [, frontmatter] = match;
27
27
  const nameLine = frontmatter.split("\n").find((line) => line.startsWith("name:"));
28
28
  if (!nameLine) {
29
29
  return undefined;
@@ -74,7 +74,7 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
74
74
 
75
75
  if (entry.isFile() && extname(entry.name) === PROMPT_EXT) {
76
76
  // 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");
77
+ const content = readFileSync(fullPath, "utf8");
78
78
  const name = extractName(content) ?? deriveNameFromPath(fullPath);
79
79
 
80
80
  if (!NAME_RE.test(name)) {
@@ -3,13 +3,28 @@ 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
+ * @param customDir - Custom partials directory path.
18
+ * @returns Array of directories to search for partials.
19
+ */
20
+ // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: checking custom partials directory from CLI config
21
+ function resolvePartialsDirs(customDir: string): readonly string[] {
22
+ if (existsSync(customDir)) {
23
+ return [customDir, PARTIALS_DIR];
24
+ }
25
+ return [PARTIALS_DIR];
26
+ }
27
+
13
28
  /**
14
29
  * Options for the prompts lint pipeline.
15
30
  */
@@ -35,14 +50,11 @@ export interface LintPipelineResult {
35
50
  export function runLintPipeline(options: LintPipelineOptions): LintPipelineResult {
36
51
  const discovered = discoverPrompts([...options.roots]);
37
52
  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];
53
+ const partialsDirs = resolvePartialsDirs(customPartialsDir);
42
54
 
43
55
  const results = discovered.map((d) => {
44
56
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
45
- const raw = readFileSync(d.filePath, "utf-8");
57
+ const raw = readFileSync(d.filePath, "utf8");
46
58
  const frontmatter = parseFrontmatter(raw, d.filePath);
47
59
  const template = flattenPartials(clean(raw), partialsDirs);
48
60
  const templateVars = extractVariables(template);
@@ -81,14 +93,11 @@ export interface GeneratePipelineResult {
81
93
  export function runGeneratePipeline(options: GeneratePipelineOptions): GeneratePipelineResult {
82
94
  const discovered = discoverPrompts([...options.roots]);
83
95
  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];
96
+ const partialsDirs = resolvePartialsDirs(customPartialsDir);
88
97
 
89
98
  const processed = discovered.map((d) => {
90
99
  // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: reading discovered prompt file
91
- const raw = readFileSync(d.filePath, "utf-8");
100
+ const raw = readFileSync(d.filePath, "utf8");
92
101
  const frontmatter = parseFrontmatter(raw, d.filePath);
93
102
  const template = flattenPartials(clean(raw), partialsDirs);
94
103
  const templateVars = extractVariables(template);