promptopskit 0.3.9 → 0.4.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.
Files changed (70) hide show
  1. package/README.md +91 -7
  2. package/SKILL.md +69 -6
  3. package/dist/{chunk-YNIAA4ZJ.js → chunk-F6UJ73Y6.js} +5 -3
  4. package/dist/chunk-F6UJ73Y6.js.map +1 -0
  5. package/dist/{chunk-O6RQZYS4.js → chunk-HLNVKKMS.js} +16 -5
  6. package/dist/chunk-HLNVKKMS.js.map +1 -0
  7. package/dist/{chunk-4LEPCCSA.js → chunk-JBLUPKY4.js} +144 -3
  8. package/dist/chunk-JBLUPKY4.js.map +1 -0
  9. package/dist/{chunk-LMURMZUA.js → chunk-L2V7LDKD.js} +5 -3
  10. package/dist/chunk-L2V7LDKD.js.map +1 -0
  11. package/dist/{chunk-AN3OR7YC.js → chunk-RY5DVTLV.js} +7 -5
  12. package/dist/chunk-RY5DVTLV.js.map +1 -0
  13. package/dist/chunk-UPTCSWK3.js +60 -0
  14. package/dist/chunk-UPTCSWK3.js.map +1 -0
  15. package/dist/{chunk-V7QY7MRM.js → chunk-XNNDGDPI.js} +50 -3
  16. package/dist/chunk-XNNDGDPI.js.map +1 -0
  17. package/dist/cli/index.js +356 -5
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/index.cjs +398 -16
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +4 -4
  22. package/dist/index.d.ts +4 -4
  23. package/dist/index.js +162 -10
  24. package/dist/index.js.map +1 -1
  25. package/dist/providers/anthropic.cjs +200 -5
  26. package/dist/providers/anthropic.cjs.map +1 -1
  27. package/dist/providers/anthropic.d.cts +2 -2
  28. package/dist/providers/anthropic.d.ts +2 -2
  29. package/dist/providers/anthropic.js +3 -3
  30. package/dist/providers/gemini.cjs +191 -5
  31. package/dist/providers/gemini.cjs.map +1 -1
  32. package/dist/providers/gemini.d.cts +2 -2
  33. package/dist/providers/gemini.d.ts +2 -2
  34. package/dist/providers/gemini.js +3 -3
  35. package/dist/providers/openai-responses.cjs +189 -3
  36. package/dist/providers/openai-responses.cjs.map +1 -1
  37. package/dist/providers/openai-responses.d.cts +2 -2
  38. package/dist/providers/openai-responses.d.ts +2 -2
  39. package/dist/providers/openai-responses.js +3 -3
  40. package/dist/providers/openai.cjs +189 -3
  41. package/dist/providers/openai.cjs.map +1 -1
  42. package/dist/providers/openai.d.cts +2 -2
  43. package/dist/providers/openai.d.ts +2 -2
  44. package/dist/providers/openai.js +3 -3
  45. package/dist/providers/openrouter.cjs +224 -6
  46. package/dist/providers/openrouter.cjs.map +1 -1
  47. package/dist/providers/openrouter.d.cts +2 -2
  48. package/dist/providers/openrouter.d.ts +2 -2
  49. package/dist/providers/openrouter.js +4 -4
  50. package/dist/{schema-BKkg93rL.d.cts → schema-B9A1zLRk.d.cts} +482 -2
  51. package/dist/{schema-BKkg93rL.d.ts → schema-B9A1zLRk.d.ts} +482 -2
  52. package/dist/testing.cjs +217 -4
  53. package/dist/testing.cjs.map +1 -1
  54. package/dist/testing.d.cts +59 -2
  55. package/dist/testing.d.ts +59 -2
  56. package/dist/testing.js +70 -2
  57. package/dist/testing.js.map +1 -1
  58. package/dist/{types-CsQ8tON_.d.ts → types-DjGAwJxC.d.ts} +1 -1
  59. package/dist/{types-7cC17GWp.d.cts → types-DpbzzZO2.d.cts} +1 -1
  60. package/dist/usagetap/index.d.cts +2 -2
  61. package/dist/usagetap/index.d.ts +2 -2
  62. package/package.json +2 -2
  63. package/dist/chunk-4LEPCCSA.js.map +0 -1
  64. package/dist/chunk-AN3OR7YC.js.map +0 -1
  65. package/dist/chunk-LMURMZUA.js.map +0 -1
  66. package/dist/chunk-O6RQZYS4.js.map +0 -1
  67. package/dist/chunk-V7QY7MRM.js.map +0 -1
  68. package/dist/chunk-YAD6COQX.js +0 -26
  69. package/dist/chunk-YAD6COQX.js.map +0 -1
  70. package/dist/chunk-YNIAA4ZJ.js.map +0 -1
package/dist/cli/index.js CHANGED
@@ -42,23 +42,42 @@ var ResponseSchema = z.object({
42
42
  stream: z.boolean().optional(),
43
43
  schema: z.record(z.unknown()).optional(),
44
44
  schema_name: z.string().optional(),
45
+ schema_description: z.string().optional(),
45
46
  schema_strict: z.boolean().optional()
46
47
  });
47
48
  var AnthropicProviderOptionsSchema = z.object({
48
49
  top_k: z.number().int().min(0).optional(),
49
- tool_choice: z.record(z.unknown()).optional()
50
+ tool_choice: z.record(z.unknown()).optional(),
51
+ output_config: z.record(z.unknown()).optional()
50
52
  });
51
53
  var GeminiProviderOptionsSchema = z.object({
52
54
  candidate_count: z.number().int().positive().optional(),
53
55
  top_k: z.number().int().min(0).optional(),
54
56
  seed: z.number().int().optional(),
55
57
  response_schema: z.record(z.unknown()).optional(),
58
+ response_json_schema: z.record(z.unknown()).optional(),
56
59
  response_modalities: z.array(z.string()).optional(),
57
60
  thinking_budget_tokens: z.number().int().positive().optional()
58
61
  });
62
+ var OpenRouterProviderOptionsSchema = z.object({
63
+ provider: z.record(z.unknown()).optional(),
64
+ transforms: z.array(z.string()).optional(),
65
+ plugins: z.array(z.record(z.unknown())).optional(),
66
+ models: z.array(z.string()).optional()
67
+ });
59
68
  var ProviderOptionsSchema = z.object({
60
69
  anthropic: AnthropicProviderOptionsSchema.optional(),
61
- gemini: GeminiProviderOptionsSchema.optional()
70
+ gemini: GeminiProviderOptionsSchema.optional(),
71
+ openrouter: OpenRouterProviderOptionsSchema.optional()
72
+ });
73
+ var RawProviderBodySchema = z.object({
74
+ openai: z.record(z.unknown()).optional(),
75
+ "openai-responses": z.record(z.unknown()).optional(),
76
+ openai_responses: z.record(z.unknown()).optional(),
77
+ anthropic: z.record(z.unknown()).optional(),
78
+ gemini: z.record(z.unknown()).optional(),
79
+ google: z.record(z.unknown()).optional(),
80
+ openrouter: z.record(z.unknown()).optional()
62
81
  });
63
82
  var OpenAICacheSchema = z.object({
64
83
  prompt_cache_key: z.string().min(1).optional(),
@@ -131,6 +150,7 @@ var PromptAssetOverridesSchema = z.object({
131
150
  sampling: SamplingSchema.optional(),
132
151
  response: ResponseSchema.optional(),
133
152
  cache: CacheSchema.optional(),
153
+ raw: RawProviderBodySchema.optional(),
134
154
  tools: z.array(ToolRefSchema).optional(),
135
155
  provider_options: ProviderOptionsSchema.optional()
136
156
  });
@@ -163,6 +183,7 @@ var PromptAssetSchema = z.object({
163
183
  sampling: SamplingSchema.optional(),
164
184
  response: ResponseSchema.optional(),
165
185
  cache: CacheSchema.optional(),
186
+ raw: RawProviderBodySchema.optional(),
166
187
  tools: z.array(ToolRefSchema).optional(),
167
188
  provider_options: ProviderOptionsSchema.optional(),
168
189
  mcp: MCPSchema.optional(),
@@ -176,6 +197,123 @@ var PromptAssetSchema = z.object({
176
197
  source: SourceSchema.optional()
177
198
  });
178
199
 
200
+ // src/parser/frontmatter-guard.ts
201
+ var FRONT_MATTER_DELIMITER = /^---[ \t]*$/;
202
+ var REGEX_FIELD = /^(?<indent>\s*)(?<field>allow_regex|deny_regex)\s*:\s*(?<value>.*)$/;
203
+ var PATTERN_FIELD = /^(?<indent>\s*)pattern\s*:\s*(?<value>.*)$/;
204
+ var INLINE_PATTERN_FIELD = /(?:^|[{,]\s*)pattern\s*:\s*(?<value>"(?:\\.|[^"\\])*")/;
205
+ function assertRegexFrontMatterQuoting(content, filePath) {
206
+ const frontMatter = extractFrontMatter(content);
207
+ if (!frontMatter) {
208
+ return;
209
+ }
210
+ let pendingRegex;
211
+ for (const [index, line] of frontMatter.lines.entries()) {
212
+ const lineNumber = frontMatter.startLine + index;
213
+ const regexMatch = line.match(REGEX_FIELD);
214
+ if (regexMatch?.groups) {
215
+ const field = regexMatch.groups.field;
216
+ const indent2 = regexMatch.groups.indent.length;
217
+ const value = regexMatch.groups.value.trim();
218
+ pendingRegex = value === "" ? { field, indent: indent2 } : void 0;
219
+ assertNoUnescapedBackslashInDoubleQuotedRegex(value, field, filePath, lineNumber);
220
+ assertNoUnescapedBackslashInInlinePattern(value, field, filePath, lineNumber);
221
+ continue;
222
+ }
223
+ if (!pendingRegex) {
224
+ continue;
225
+ }
226
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
227
+ continue;
228
+ }
229
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
230
+ if (indent <= pendingRegex.indent) {
231
+ pendingRegex = void 0;
232
+ continue;
233
+ }
234
+ const patternMatch = line.match(PATTERN_FIELD);
235
+ if (!patternMatch?.groups) {
236
+ continue;
237
+ }
238
+ assertNoUnescapedBackslashInDoubleQuotedRegex(
239
+ patternMatch.groups.value.trim(),
240
+ `${pendingRegex.field}.pattern`,
241
+ filePath,
242
+ lineNumber
243
+ );
244
+ }
245
+ }
246
+ function assertNoUnescapedBackslashInInlinePattern(value, field, filePath, lineNumber) {
247
+ const match = value.match(INLINE_PATTERN_FIELD);
248
+ const pattern = match?.groups?.value;
249
+ if (!pattern) {
250
+ return;
251
+ }
252
+ assertNoUnescapedBackslashInDoubleQuotedRegex(pattern, `${field}.pattern`, filePath, lineNumber);
253
+ }
254
+ function extractFrontMatter(content) {
255
+ const lines = content.split(/\r?\n/);
256
+ if (!FRONT_MATTER_DELIMITER.test(lines[0] ?? "")) {
257
+ return void 0;
258
+ }
259
+ for (let index = 1; index < lines.length; index += 1) {
260
+ if (FRONT_MATTER_DELIMITER.test(lines[index] ?? "")) {
261
+ return {
262
+ lines: lines.slice(1, index),
263
+ startLine: 2
264
+ };
265
+ }
266
+ }
267
+ return void 0;
268
+ }
269
+ function assertNoUnescapedBackslashInDoubleQuotedRegex(value, field, filePath, lineNumber) {
270
+ if (!value.startsWith('"')) {
271
+ return;
272
+ }
273
+ const quoted = readDoubleQuotedScalar(value);
274
+ if (!quoted) {
275
+ return;
276
+ }
277
+ if (!hasUnescapedBackslash(quoted)) {
278
+ return;
279
+ }
280
+ const location = filePath ? `${filePath}:${lineNumber}` : `line ${lineNumber}`;
281
+ throw new Error(
282
+ `POK013: Invalid context regex YAML at ${location}, field "${field}": double-quoted regex strings treat backslashes as YAML escapes. Use unquoted /pattern/i literal form, single quotes, or double each backslash.`
283
+ );
284
+ }
285
+ function readDoubleQuotedScalar(value) {
286
+ let result = "";
287
+ for (let index = 1; index < value.length; index += 1) {
288
+ const char = value[index];
289
+ if (char === '"') {
290
+ return result;
291
+ }
292
+ result += char;
293
+ if (char === "\\" && index + 1 < value.length) {
294
+ index += 1;
295
+ result += value[index];
296
+ }
297
+ }
298
+ return void 0;
299
+ }
300
+ function hasUnescapedBackslash(value) {
301
+ for (let index = 0; index < value.length; index += 1) {
302
+ if (value[index] !== "\\") {
303
+ continue;
304
+ }
305
+ let count = 1;
306
+ while (value[index + count] === "\\") {
307
+ count += 1;
308
+ }
309
+ if (count % 2 === 1) {
310
+ return true;
311
+ }
312
+ index += count - 1;
313
+ }
314
+ return false;
315
+ }
316
+
179
317
  // src/parser/sections.ts
180
318
  var SECTION_MAP = {
181
319
  "system instructions": "system_instructions",
@@ -216,6 +354,7 @@ function extractSections(body) {
216
354
 
217
355
  // src/parser/parser.ts
218
356
  function parsePrompt(content, filePath) {
357
+ assertRegexFrontMatterQuoting(content, filePath);
219
358
  const { data: frontMatter, content: body } = matter(content);
220
359
  const sections = extractSections(body);
221
360
  const raw = {
@@ -542,8 +681,21 @@ var KNOWN_FRONT_MATTER_KEYS = /* @__PURE__ */ new Set([
542
681
  "environments",
543
682
  "tiers",
544
683
  "metadata",
545
- "cache"
684
+ "cache",
685
+ "provider_options",
686
+ "raw"
546
687
  ]);
688
+ var RISKY_UNBOUNDED_INPUT_NAMES = [
689
+ "message",
690
+ "prompt",
691
+ "history",
692
+ "transcript",
693
+ "document",
694
+ "content",
695
+ "input",
696
+ "body",
697
+ "context"
698
+ ];
547
699
  function validateAsset(asset, frontMatterKeys, filePath) {
548
700
  const errors = [];
549
701
  const warnings = [];
@@ -614,7 +766,32 @@ function validateAsset(asset, frontMatterKeys, filePath) {
614
766
  });
615
767
  }
616
768
  }
769
+ if (usedVars.size > 0 && (!asset.context?.inputs || asset.context.inputs.length === 0)) {
770
+ warnings.push({
771
+ code: "POK046",
772
+ message: `Template uses ${usedVars.size === 1 ? "a variable" : "variables"} but context.inputs is not declared.`,
773
+ filePath,
774
+ suggestion: "Declare context.inputs to enable input policy validation."
775
+ });
776
+ }
617
777
  for (const input of getContextInputs(asset)) {
778
+ const lowerName = input.name.toLowerCase();
779
+ if (input.max_size === void 0 && RISKY_UNBOUNDED_INPUT_NAMES.some((needle) => lowerName.includes(needle))) {
780
+ warnings.push({
781
+ code: "POK040",
782
+ message: `Context input "${input.name}" has no max_size and appears unbounded.`,
783
+ filePath,
784
+ suggestion: "Add max_size to constrain prompt payload growth."
785
+ });
786
+ }
787
+ if (input.allow_regex === void 0 && input.deny_regex === void 0 && input.non_empty === void 0 && input.reject_secrets === void 0) {
788
+ warnings.push({
789
+ code: "POK041",
790
+ message: `Context input "${input.name}" has no input hardening validators.`,
791
+ filePath,
792
+ suggestion: "Consider non_empty/reject_secrets and allow/deny regex validators."
793
+ });
794
+ }
618
795
  if (input.trim !== void 0 && input.trim !== false && input.max_size === void 0) {
619
796
  warnings.push({
620
797
  code: "POK014",
@@ -642,6 +819,91 @@ function validateAsset(asset, frontMatterKeys, filePath) {
642
819
  }
643
820
  }
644
821
  }
822
+ if (asset.provider) {
823
+ let providerCache;
824
+ let cacheSuggestionField;
825
+ switch (asset.provider) {
826
+ case "openai":
827
+ providerCache = asset.cache?.openai;
828
+ cacheSuggestionField = "cache.openai";
829
+ break;
830
+ case "anthropic":
831
+ providerCache = asset.cache?.anthropic;
832
+ cacheSuggestionField = "cache.anthropic";
833
+ break;
834
+ case "gemini":
835
+ case "google":
836
+ providerCache = asset.cache?.gemini ?? asset.cache?.google;
837
+ cacheSuggestionField = "cache.gemini";
838
+ break;
839
+ default:
840
+ break;
841
+ }
842
+ if (cacheSuggestionField && providerCache === void 0) {
843
+ warnings.push({
844
+ code: "POK042",
845
+ message: `Provider "${asset.provider}" has no provider-specific cache settings.`,
846
+ filePath,
847
+ suggestion: `Consider configuring ${cacheSuggestionField} for better cache-hit behavior.`
848
+ });
849
+ }
850
+ if (!asset.model) {
851
+ warnings.push({
852
+ code: "POK044",
853
+ message: `Provider "${asset.provider}" is configured without a model.`,
854
+ filePath,
855
+ suggestion: "Set model in prompt or defaults to avoid adapter-time errors."
856
+ });
857
+ }
858
+ }
859
+ if (asset.cache?.gemini?.cached_content && asset.cache.google?.cached_content && asset.cache.gemini.cached_content !== asset.cache.google.cached_content) {
860
+ warnings.push({
861
+ code: "POK043",
862
+ message: "cache.gemini.cached_content and cache.google.cached_content are both set to different values.",
863
+ filePath,
864
+ suggestion: "Use one canonical value; Gemini prefers cache.gemini.cached_content."
865
+ });
866
+ }
867
+ for (const [envName, overrides] of Object.entries(asset.environments ?? {})) {
868
+ if (asset.cache && (!isRecord(overrides) || !overrides.cache)) {
869
+ warnings.push({
870
+ code: "POK045",
871
+ message: `Environment "${envName}" does not override cache while prompt-level cache is defined.`,
872
+ filePath,
873
+ suggestion: "Confirm cache strategy is intentionally shared across environments."
874
+ });
875
+ }
876
+ }
877
+ for (const [tierName, overrides] of Object.entries(asset.tiers ?? {})) {
878
+ if (asset.cache && (!isRecord(overrides) || !overrides.cache)) {
879
+ warnings.push({
880
+ code: "POK045",
881
+ message: `Tier "${tierName}" does not override cache while prompt-level cache is defined.`,
882
+ filePath,
883
+ suggestion: "Confirm cache strategy is intentionally shared across tiers."
884
+ });
885
+ }
886
+ }
887
+ for (const tool of asset.tools ?? []) {
888
+ if (isRecord(tool)) {
889
+ if (!tool.description) {
890
+ warnings.push({
891
+ code: "POK047",
892
+ message: `Inline tool "${tool.name}" is missing a description.`,
893
+ filePath,
894
+ suggestion: "Add description to improve model tool-selection quality."
895
+ });
896
+ }
897
+ if (!tool.input_schema) {
898
+ warnings.push({
899
+ code: "POK047",
900
+ message: `Inline tool "${tool.name}" is missing input_schema.`,
901
+ filePath,
902
+ suggestion: "Add input_schema so tool inputs are strongly typed."
903
+ });
904
+ }
905
+ }
906
+ }
645
907
  return {
646
908
  valid: errors.length === 0,
647
909
  errors,
@@ -678,6 +940,9 @@ function findClosestMatch(input, candidates) {
678
940
  }
679
941
  return best;
680
942
  }
943
+ function isRecord(value) {
944
+ return typeof value === "object" && value !== null && !Array.isArray(value);
945
+ }
681
946
 
682
947
  // src/prompt-resolution.ts
683
948
  import { readFile as readFile3 } from "fs/promises";
@@ -758,6 +1023,29 @@ function mergeOverride(base, override) {
758
1023
  if (override.response !== void 0) {
759
1024
  result.response = { ...result.response, ...override.response };
760
1025
  }
1026
+ if (override.cache !== void 0) {
1027
+ result.cache = {
1028
+ ...result.cache,
1029
+ ...override.cache,
1030
+ openai: mergeRecordBlock(result.cache?.openai, override.cache.openai),
1031
+ anthropic: mergeRecordBlock(result.cache?.anthropic, override.cache.anthropic),
1032
+ gemini: mergeRecordBlock(result.cache?.gemini, override.cache.gemini),
1033
+ google: mergeRecordBlock(result.cache?.google, override.cache.google)
1034
+ };
1035
+ }
1036
+ if (override.raw !== void 0) {
1037
+ result.raw = {
1038
+ ...result.raw,
1039
+ ...override.raw,
1040
+ openai: mergeRecordBlock(result.raw?.openai, override.raw.openai),
1041
+ "openai-responses": mergeRecordBlock(result.raw?.["openai-responses"], override.raw["openai-responses"]),
1042
+ openai_responses: mergeRecordBlock(result.raw?.openai_responses, override.raw.openai_responses),
1043
+ anthropic: mergeRecordBlock(result.raw?.anthropic, override.raw.anthropic),
1044
+ gemini: mergeRecordBlock(result.raw?.gemini, override.raw.gemini),
1045
+ google: mergeRecordBlock(result.raw?.google, override.raw.google),
1046
+ openrouter: mergeRecordBlock(result.raw?.openrouter, override.raw.openrouter)
1047
+ };
1048
+ }
761
1049
  if (override.provider_options !== void 0) {
762
1050
  result.provider_options = {
763
1051
  ...result.provider_options,
@@ -769,11 +1057,19 @@ function mergeOverride(base, override) {
769
1057
  gemini: {
770
1058
  ...result.provider_options?.gemini,
771
1059
  ...override.provider_options.gemini
1060
+ },
1061
+ openrouter: {
1062
+ ...result.provider_options?.openrouter,
1063
+ ...override.provider_options.openrouter
772
1064
  }
773
1065
  };
774
1066
  }
775
1067
  return result;
776
1068
  }
1069
+ function mergeRecordBlock(base, override) {
1070
+ if (override === void 0) return base;
1071
+ return { ...base, ...override };
1072
+ }
777
1073
 
778
1074
  // src/prompt-resolution.ts
779
1075
  var DEFAULT_PROMPTS_DIR = "./prompts";
@@ -1157,7 +1453,7 @@ async function inspect(args) {
1157
1453
 
1158
1454
  // src/cli/commands/init.ts
1159
1455
  import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1160
- import { join as join5, dirname as dirname5 } from "path";
1456
+ import { join as join5, dirname as dirname5, relative as relative2 } from "path";
1161
1457
  import { existsSync as existsSync4, readFileSync } from "fs";
1162
1458
  var HELP5 = `
1163
1459
  promptopskit init [dir]
@@ -1229,10 +1525,14 @@ var TEST_SIDECAR = `cases:
1229
1525
  variables:
1230
1526
  name: "World"
1231
1527
  app_context: "Welcome screen"
1528
+ response:
1529
+ message: "Hello, World! How can I help you today?"
1232
1530
  - name: named-greeting
1233
1531
  variables:
1234
1532
  name: "Alice"
1235
1533
  app_context: "Settings page"
1534
+ response:
1535
+ message: "Hello, Alice! How can I help you today?"
1236
1536
  `;
1237
1537
  var EXAMPLE_USAGE = `// Example: render the hello prompt and send it to OpenAI
1238
1538
  // Full docs: https://promptopskit.com/docs/index.html#/
@@ -1304,12 +1604,16 @@ async function init(args) {
1304
1604
  return;
1305
1605
  }
1306
1606
  const dir = args.find((a) => !a.startsWith("--")) ?? "./prompts";
1607
+ const testFilePath = join5(dirname5(dir), "tests", "hello.prompt.test.mjs");
1608
+ const promptsDirFromTest = relative2(dirname5(testFilePath), dir) || ".";
1609
+ const helloPromptTest = createHelloPromptTest(promptsDirFromTest);
1307
1610
  const files = [
1308
1611
  { path: join5(dir, "defaults.md"), content: DEFAULTS },
1309
1612
  { path: join5(dir, "hello.md"), content: HELLO_PROMPT },
1310
1613
  { path: join5(dir, "hello.test.yaml"), content: TEST_SIDECAR },
1311
1614
  { path: join5(dir, "shared", "tone.md"), content: TONE_INCLUDE },
1312
- { path: join5(dir, "example-usage.ts"), content: EXAMPLE_USAGE }
1615
+ { path: join5(dir, "example-usage.ts"), content: EXAMPLE_USAGE },
1616
+ { path: testFilePath, content: helloPromptTest }
1313
1617
  ];
1314
1618
  let created = 0;
1315
1619
  let skipped = 0;
@@ -1334,10 +1638,57 @@ async function init(args) {
1334
1638
  console.log(`Tip: Add to your package.json scripts:`);
1335
1639
  console.log(` "build:prompts": "promptopskit compile ${dir}"`);
1336
1640
  }
1641
+ if (!pkg.scripts?.test) {
1642
+ console.log();
1643
+ console.log(`Tip: Add a test script to run the generated prompt test:`);
1644
+ console.log(` "test": "node --test tests/*.test.mjs"`);
1645
+ }
1337
1646
  } catch {
1338
1647
  }
1339
1648
  }
1340
1649
  }
1650
+ function createHelloPromptTest(promptsDirFromTest) {
1651
+ return `import assert from 'node:assert/strict';
1652
+ import { dirname, resolve } from 'node:path';
1653
+ import test from 'node:test';
1654
+ import { fileURLToPath } from 'node:url';
1655
+ import { createPromptOpsKit } from 'promptopskit';
1656
+ import {
1657
+ getHardcodedPromptResponse,
1658
+ loadPromptTestSidecar,
1659
+ renderPromptTestCase,
1660
+ } from 'promptopskit/testing';
1661
+
1662
+ const testDir = dirname(fileURLToPath(import.meta.url));
1663
+ const promptsDir = resolve(testDir, ${JSON.stringify(promptsDirFromTest)});
1664
+
1665
+ test('hello prompt renders every sidecar case', async () => {
1666
+ const kit = createPromptOpsKit({ sourceDir: promptsDir, cache: false });
1667
+ const sidecar = await loadPromptTestSidecar(resolve(promptsDir, 'hello.test.yaml'));
1668
+
1669
+ for (const testCase of sidecar.cases) {
1670
+ const { rendered } = await renderPromptTestCase(kit, {
1671
+ sidecar,
1672
+ caseName: testCase.name,
1673
+ path: 'hello',
1674
+ provider: 'openai',
1675
+ environment: 'dev',
1676
+ strict: true,
1677
+ });
1678
+
1679
+ assert.ok(rendered.request?.body?.messages);
1680
+ }
1681
+ });
1682
+
1683
+ test('hello prompt can return a deterministic response without calling a model', async () => {
1684
+ const sidecar = await loadPromptTestSidecar(resolve(promptsDir, 'hello.test.yaml'));
1685
+
1686
+ assert.deepEqual(getHardcodedPromptResponse(sidecar, 'basic-greeting'), {
1687
+ message: 'Hello, World! How can I help you today?',
1688
+ });
1689
+ });
1690
+ `;
1691
+ }
1341
1692
 
1342
1693
  // src/cli/commands/skill.ts
1343
1694
  import { writeFile as writeFile3, readFile as readFile5, mkdir as mkdir3 } from "fs/promises";