@funkai/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,137 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
1
4
  import { command } from "@kidd-cli/core";
5
+ import { match } from "ts-pattern";
6
+
7
+ import { setupPrompts } from "@/commands/prompts/setup.js";
8
+
9
+ /** @private */
10
+ const CONFIG_TEMPLATE_AGENTS_ONLY = `import { defineConfig } from "@funkai/config";
11
+
12
+ export default defineConfig({
13
+ agents: {},
14
+ });
15
+ `;
2
16
 
3
17
  export default command({
4
18
  description: "Set up your project for the funkai SDK",
5
19
  async handler(ctx) {
6
20
  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.");
21
+
22
+ // --- Domain selection ---
23
+ const domains = await ctx.prompts.multiselect({
24
+ message: "Which domains do you want to set up?",
25
+ options: [
26
+ {
27
+ value: "prompts" as const,
28
+ label: "Prompts",
29
+ hint: "LiquidJS templating, codegen, IDE integration",
30
+ },
31
+ { value: "agents" as const, label: "Agents", hint: "Agent scaffolding and configuration" },
32
+ ],
33
+ initialValues: ["prompts" as const],
34
+ required: true,
35
+ });
36
+
37
+ const hasPrompts = domains.includes("prompts");
38
+ const hasAgents = domains.includes("agents");
39
+
40
+ // --- Create funkai.config.ts ---
41
+ const shouldCreateConfig = await ctx.prompts.confirm({
42
+ message: "Create funkai.config.ts?",
43
+ initialValue: true,
44
+ });
45
+
46
+ if (shouldCreateConfig) {
47
+ let includes = ["src/prompts/**"];
48
+ let out = ".prompts/client";
49
+
50
+ if (hasPrompts) {
51
+ const includesInput = await ctx.prompts.text({
52
+ message: "Prompt include patterns (comma-separated)",
53
+ defaultValue: "src/prompts/**",
54
+ placeholder: "src/prompts/**",
55
+ });
56
+ includes = includesInput.split(",").map((r) => r.trim());
57
+
58
+ out = await ctx.prompts.text({
59
+ message: "Output directory for generated prompt modules",
60
+ defaultValue: ".prompts/client",
61
+ placeholder: ".prompts/client",
62
+ });
63
+ }
64
+
65
+ const template = buildConfigTemplate({ hasPrompts, hasAgents, includes, out });
66
+ const configPath = resolve("funkai.config.ts");
67
+ // oxlint-disable-next-line security/detect-non-literal-fs-filename -- safe: writing config file to project root
68
+ writeFileSync(configPath, template, "utf8");
69
+ ctx.logger.success(`Created ${configPath}`);
70
+ }
71
+
72
+ // --- Run domain-specific setup ---
73
+ if (hasPrompts) {
74
+ ctx.logger.info("");
75
+ ctx.logger.info("Configuring Prompts...");
76
+ await setupPrompts(ctx);
77
+ }
78
+
79
+ if (hasAgents) {
80
+ ctx.logger.info("");
81
+ ctx.logger.info("Agents configuration is not yet available.");
82
+ ctx.logger.info("The agents section has been added to your config for future use.");
83
+ }
84
+
85
+ ctx.logger.outro("Project setup complete.");
11
86
  },
12
87
  });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Private
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** @private */
94
+ interface ConfigTemplateOptions {
95
+ readonly hasPrompts: boolean;
96
+ readonly hasAgents: boolean;
97
+ readonly includes: readonly string[];
98
+ readonly out: string;
99
+ }
100
+
101
+ /** @private */
102
+ function buildConfigTemplate({
103
+ hasPrompts,
104
+ hasAgents,
105
+ includes,
106
+ out,
107
+ }: ConfigTemplateOptions): string {
108
+ if (hasPrompts && hasAgents) {
109
+ return buildCustomTemplate(includes, out, true);
110
+ }
111
+ if (hasPrompts) {
112
+ return buildCustomTemplate(includes, out, false);
113
+ }
114
+ return CONFIG_TEMPLATE_AGENTS_ONLY;
115
+ }
116
+
117
+ /** @private */
118
+ function buildCustomTemplate(
119
+ includes: readonly string[],
120
+ out: string,
121
+ includeAgents: boolean,
122
+ ): string {
123
+ const includesStr = includes.map((r) => `"${r}"`).join(", ");
124
+ const agentsBlock = match(includeAgents)
125
+ .with(true, () => "\n agents: {},\n")
126
+ .with(false, () => "\n")
127
+ .exhaustive();
128
+
129
+ return `import { defineConfig } from "@funkai/config";
130
+
131
+ export default defineConfig({
132
+ prompts: {
133
+ includes: [${includesStr}],
134
+ out: "${out}",
135
+ },${agentsBlock}});
136
+ `;
137
+ }
@@ -1,19 +1,26 @@
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
+ handleLint({
19
+ args: ctx.args,
20
+ config: config.prompts,
21
+ logger: ctx.logger,
22
+ fail: ctx.fail,
23
+ });
17
24
 
18
25
  // --- Future: agents validation ---
19
26
 
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,
@@ -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,79 @@ 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
+ let sourceLine = "";
112
+ if (sourcePath) {
113
+ sourceLine = `// Source: ${sourcePath}\n`;
114
+ }
115
+ return [
116
+ "// ─── AUTO-GENERATED ────────────────────────────────────────",
117
+ `${sourceLine}// Regenerate: funkai prompts generate`,
118
+ "// ───────────────────────────────────────────────────────────",
119
+ ].join("\n");
120
+ }
121
+
122
+ /**
123
+ * Derive a unique file slug from group + name.
124
+ *
125
+ * Ungrouped prompts use the name alone. Grouped prompts
126
+ * join group segments and name with hyphens.
127
+ *
128
+ * @param name - The prompt name (kebab-case).
129
+ * @param group - Optional group path (e.g., 'core/agent').
130
+ * @returns The file slug string.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * toFileSlug('system', 'core/agent') // => 'core-agent-system'
135
+ * toFileSlug('greeting', undefined) // => 'greeting'
136
+ * ```
137
+ */
138
+ export function toFileSlug(name: string, group?: string): string {
139
+ if (group) {
140
+ return `${group.replaceAll("/", "-")}-${name}`;
141
+ }
142
+ return name;
143
+ }
144
+
145
+ /**
146
+ * Derive a unique import name (camelCase) from group + name.
147
+ *
148
+ * @param name - The prompt name (kebab-case).
149
+ * @param group - Optional group path (e.g., 'core/agent').
150
+ * @returns The camelCase import identifier.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * toImportName('system', 'core/agent') // => 'coreAgentSystem'
155
+ * toImportName('greeting', undefined) // => 'greeting'
156
+ * ```
157
+ */
158
+ export function toImportName(name: string, group?: string): string {
159
+ return toCamelCase(toFileSlug(name, group));
160
+ }
111
161
 
112
162
  /**
113
163
  * Generate a per-prompt TypeScript module with a default export.
114
164
  *
115
- * The module contains the Zod schema, inlined template, and
116
- * `render` / `validate` functions.
165
+ * The module uses `createPrompt` from `@funkai/prompts` to
166
+ * encapsulate the Zod schema, inlined template, and render logic.
167
+ *
168
+ * @param prompt - The parsed prompt configuration.
169
+ * @returns The generated TypeScript module source code.
117
170
  */
118
171
  export function generatePromptModule(prompt: ParsedPrompt): string {
119
172
  const escaped = escapeTemplateLiteral(prompt.template);
120
173
  const schemaExpr = generateSchemaExpression(prompt.schema);
121
174
  const groupValue = formatGroupValue(prompt.group);
175
+ const header = formatHeader(prompt.sourcePath);
122
176
 
123
177
  const lines: readonly string[] = [
124
- HEADER,
125
- `// Source: ${prompt.sourcePath}`,
178
+ header,
126
179
  "",
127
180
  "import { z } from 'zod'",
128
- "import { liquidEngine } from '@funkai/prompts/runtime'",
181
+ "import { createPrompt } from '@funkai/prompts'",
129
182
  "",
130
183
  `const schema = ${schemaExpr}`,
131
184
  "",
@@ -133,28 +186,16 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
133
186
  "",
134
187
  `const template = \`${escaped}\``,
135
188
  "",
136
- "export default {",
137
- ` name: '${prompt.name}' as const,`,
189
+ "/**",
190
+ ` * **${prompt.name}** prompt module.`,
191
+ ...formatGroupJsdoc(prompt.group),
192
+ " */",
193
+ "export default createPrompt<Variables>({",
194
+ ` name: '${prompt.name}',`,
138
195
  ` group: ${groupValue},`,
196
+ " template,",
139
197
  " 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
- "}",
198
+ "})",
158
199
  "",
159
200
  ];
160
201
 
@@ -172,6 +213,9 @@ interface TreeNode {
172
213
  /**
173
214
  * Build a nested tree from sorted prompts, grouped by their `group` field.
174
215
  *
216
+ * Leaf values are the unique import name derived from group+name,
217
+ * so prompts with the same name in different groups do not collide.
218
+ *
175
219
  * @param prompts - Sorted parsed prompts.
176
220
  * @returns A tree where leaves are import names and branches are group namespaces.
177
221
  * @throws If a prompt name collides with a group namespace at the same level.
@@ -180,7 +224,8 @@ interface TreeNode {
180
224
  */
181
225
  function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
182
226
  return prompts.reduce<Record<string, unknown>>((root, prompt) => {
183
- const importName = toCamelCase(prompt.name);
227
+ const leafKey = toCamelCase(prompt.name);
228
+ const importName = toImportName(prompt.name, prompt.group);
184
229
  const segments = parseGroupSegments(prompt.group);
185
230
 
186
231
  const target = segments.reduce<Record<string, unknown>>((current, segment) => {
@@ -196,13 +241,13 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
196
241
  return current[segment] as Record<string, unknown>;
197
242
  }, root);
198
243
 
199
- if (typeof target[importName] === "object" && target[importName] !== null) {
244
+ if (typeof target[leafKey] === "object" && target[leafKey] !== null) {
200
245
  throw new Error(
201
- `Collision: prompt "${importName}" conflicts with existing group namespace "${importName}" at the same level.`,
246
+ `Collision: prompt "${leafKey}" conflicts with existing group namespace "${leafKey}" at the same level.`,
202
247
  );
203
248
  }
204
249
 
205
- target[importName] = importName;
250
+ target[leafKey] = importName;
206
251
  return root;
207
252
  }, {}) as TreeNode;
208
253
  }
@@ -221,7 +266,12 @@ function serializeTree(node: TreeNode, indent: number): readonly string[] {
221
266
 
222
267
  return Object.entries(node).flatMap(([key, value]) =>
223
268
  match(typeof value)
224
- .with("string", () => [`${pad}${key},`])
269
+ .with("string", () => {
270
+ if (key === value) {
271
+ return [`${pad}${key},`];
272
+ }
273
+ return [`${pad}${key}: ${value as string},`];
274
+ })
225
275
  .otherwise(() => [
226
276
  `${pad}${key}: {`,
227
277
  ...serializeTree(value as TreeNode, indent + 1),
@@ -236,19 +286,39 @@ function serializeTree(node: TreeNode, indent: number): readonly string[] {
236
286
  *
237
287
  * Prompts are organized into a nested object structure based on their
238
288
  * `group` field, with each `/`-separated segment becoming a nesting level.
289
+ *
290
+ * @param prompts - Sorted parsed prompts to include in the registry.
291
+ * @returns The generated TypeScript source for the registry index module.
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * const source = generateRegistry([
296
+ * { name: 'system', group: 'core/agent', schema: [], template: '...', sourcePath: 'prompts/system.prompt' },
297
+ * ])
298
+ * writeFileSync('index.ts', source)
299
+ * ```
239
300
  */
240
301
  export function generateRegistry(prompts: readonly ParsedPrompt[]): string {
241
- const sorted = [...prompts].toSorted((a, b) => a.name.localeCompare(b.name));
302
+ const sorted = [...prompts].toSorted((a, b) => {
303
+ const slugA = toFileSlug(a.name, a.group);
304
+ const slugB = toFileSlug(b.name, b.group);
305
+ return slugA.localeCompare(slugB);
306
+ });
242
307
 
243
308
  const imports = sorted
244
- .map((p) => `import ${toCamelCase(p.name)} from './${p.name}.js'`)
309
+ .map((p) => {
310
+ const importName = toImportName(p.name, p.group);
311
+ const fileSlug = toFileSlug(p.name, p.group);
312
+ return `import ${importName} from './${fileSlug}.js'`;
313
+ })
245
314
  .join("\n");
246
315
 
247
316
  const tree = buildTree(sorted);
248
317
  const treeLines = serializeTree(tree, 1);
318
+ const header = formatHeader();
249
319
 
250
320
  const lines: readonly string[] = [
251
- HEADER,
321
+ header,
252
322
  "",
253
323
  "import { createPromptRegistry } from '@funkai/prompts'",
254
324
  imports,
@@ -1,6 +1,7 @@
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
3
 
4
+ import picomatch from "picomatch";
4
5
  import { parse as parseYaml } from "yaml";
5
6
 
6
7
  import { FRONTMATTER_RE, NAME_RE } from "./frontmatter.js";
@@ -14,6 +15,16 @@ export interface DiscoveredPrompt {
14
15
  readonly filePath: string;
15
16
  }
16
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
+
17
28
  // ---------------------------------------------------------------------------
18
29
  // Private
19
30
  // ---------------------------------------------------------------------------
@@ -58,6 +69,34 @@ function deriveNameFromPath(filePath: string): string {
58
69
  return stem;
59
70
  }
60
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
+
61
100
  /**
62
101
  * Recursively scan a directory for `.prompt` files.
63
102
  *
@@ -110,23 +149,36 @@ function scanDirectory(dir: string, depth: number): DiscoveredPrompt[] {
110
149
  }
111
150
 
112
151
  /**
113
- * Discover all `.prompt` files from the given root directories.
152
+ * Discover all `.prompt` files matching the given include/exclude patterns.
114
153
  *
115
- * @param roots - Directories to scan recursively.
116
- * @returns Sorted, deduplicated list of discovered prompts.
117
- * @throws If duplicate prompt names are found across roots.
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.
161
+ *
162
+ * @param options - Include and exclude glob patterns.
163
+ * @returns Sorted list of discovered prompts.
118
164
  */
119
- export function discoverPrompts(roots: readonly string[]): readonly DiscoveredPrompt[] {
120
- const all = roots.flatMap((root) => scanDirectory(resolve(root), 0));
165
+ export function discoverPrompts(options: DiscoverPromptsOptions): readonly DiscoveredPrompt[] {
166
+ const { includes, excludes = [] } = options;
121
167
 
122
- 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));
123
170
 
124
- const duplicate = [...byName.entries()].find(([, prompts]) => prompts.length > 1);
125
- if (duplicate) {
126
- const [name, prompts] = duplicate;
127
- const paths = prompts.map((p) => p.filePath).join("\n ");
128
- throw new Error(`Duplicate prompt name "${name}" found in:\n ${paths}`);
129
- }
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
+ ];
130
182
 
131
- return all.toSorted((a, b) => a.name.localeCompare(b.name));
183
+ return deduped.toSorted((a, b) => a.name.localeCompare(b.name));
132
184
  }