diff --git a/packages/prompts-core/src/loader.test.ts b/packages/prompts-core/src/loader.test.ts index 2842ab640..12fb333fc 100644 --- a/packages/prompts-core/src/loader.test.ts +++ b/packages/prompts-core/src/loader.test.ts @@ -1,13 +1,19 @@ import { describe, expect, test } from "bun:test" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" -import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader" -import type { PromptSource } from "./types" +import { loadPrompt, loadPromptSync, PromptFileNotFoundError, PromptPathTraversalError } from "./loader" +import type { BundledPromptSource, PromptSource } from "./types" const fixtureSource: PromptSource = { baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"), } +const bundledSource: BundledPromptSource = { + kind: "bundled", + content: "Bundled prompt body with {A}, {B}, and {C}.\n", + filePath: "packages/prompts-core/prompts/test/default.md", +} + class ResolverFailureError extends Error { readonly name = "ResolverFailureError" } @@ -92,6 +98,34 @@ describe("loadPrompt", () => { expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n") }) + test("#given bundled prompt source #then returns synchronously with multiple injections", () => { + const prompt = loadPrompt({ + source: bundledSource, + name: "test-prompt", + variant: "default", + inject: [ + { placeholder: "{A}", resolver: () => "Alpha" }, + { placeholder: "{B}", resolver: () => "Beta" }, + { placeholder: "{C}", resolver: () => "Gamma" }, + ], + }) + + expect(prompt.body).toBe("Bundled prompt body with Alpha, Beta, and Gamma.\n") + expect(prompt.filePath).toBe("packages/prompts-core/prompts/test/default.md") + }) + + test("#given bundled prompt source #when using sync loader #then returns synchronously", () => { + const prompt = loadPromptSync({ + source: bundledSource, + name: "test-prompt", + variant: "default", + inject: [{ placeholder: "{A}", resolver: () => "Alpha" }], + }) + + expect(prompt.body).toBe("Bundled prompt body with Alpha, {B}, and {C}.\n") + expect(prompt.filePath).toBe("packages/prompts-core/prompts/test/default.md") + }) + test("#given injection resolver throws #then propagates the error", async () => { const error = await captureError(() => loadPrompt({ diff --git a/packages/prompts-core/src/loader.ts b/packages/prompts-core/src/loader.ts index df01b4579..6f9e34e56 100644 --- a/packages/prompts-core/src/loader.ts +++ b/packages/prompts-core/src/loader.ts @@ -1,7 +1,14 @@ import { parseFrontmatter } from "@oh-my-opencode/utils" import { readFile } from "node:fs/promises" import { isAbsolute, relative, resolve } from "node:path" -import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types" +import type { + LoadedPrompt, + LoadBundledPromptInput, + LoadFilesystemPromptInput, + LoadPromptInput, + RuntimeInjection, + SyncRuntimeInjection, +} from "./types" export class PromptFileNotFoundError extends Error { readonly name = "PromptFileNotFoundError" @@ -27,8 +34,31 @@ export class PromptPathTraversalError extends Error { } } -export async function loadPrompt>( +export function loadPrompt>( + input: LoadBundledPromptInput +): LoadedPrompt +export function loadPrompt>( + input: LoadFilesystemPromptInput +): Promise> +export function loadPrompt>( input: LoadPromptInput +): LoadedPrompt | Promise> { + if (isLoadBundledPromptInput(input)) return loadBundledPrompt(input) + return loadFilesystemPrompt(input) +} + +export function loadPromptSync>( + input: LoadBundledPromptInput +): LoadedPrompt { + return loadBundledPrompt(input) +} + +function isLoadBundledPromptInput(input: LoadPromptInput): input is LoadBundledPromptInput { + return input.source.kind === "bundled" +} + +async function loadFilesystemPrompt>( + input: LoadFilesystemPromptInput ): Promise> { const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant) const content = await readPromptFile(input.name, input.variant, filePath) @@ -44,6 +74,21 @@ export async function loadPrompt>( } } +function loadBundledPrompt>( + input: LoadBundledPromptInput +): LoadedPrompt { + const parsed = parseFrontmatter(input.source.content) + const body = applyRuntimeInjectionsSync(parsed.body, input.inject ?? []) + + return { + frontmatter: parsed.data, + body, + hadFrontmatter: parsed.hadFrontmatter, + parseError: parsed.parseError, + filePath: input.source.filePath, + } +} + function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string { const resolvedBaseDir = resolve(baseDir) const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`) @@ -76,6 +121,17 @@ async function applyRuntimeInjections( return renderedBody } +function applyRuntimeInjectionsSync( + body: string, + injections: readonly SyncRuntimeInjection[] +): string { + let renderedBody = body + for (const injection of injections) { + renderedBody = renderedBody.replaceAll(injection.placeholder, injection.resolver()) + } + return renderedBody +} + function getErrorCode(error: Error): string | undefined { if (!("code" in error)) return undefined return typeof error.code === "string" ? error.code : undefined diff --git a/packages/prompts-core/src/types.ts b/packages/prompts-core/src/types.ts index c70351455..1fb30be23 100644 --- a/packages/prompts-core/src/types.ts +++ b/packages/prompts-core/src/types.ts @@ -8,22 +8,45 @@ export type ModelVariant = | "opus-4-7" | "minimax" -export type PromptSource = { +export type FilesystemPromptSource = { + readonly kind?: "filesystem" readonly baseDir: string } +export type BundledPromptSource = { + readonly kind: "bundled" + readonly content: string + readonly filePath: string +} + +export type PromptSource = FilesystemPromptSource | BundledPromptSource + export type RuntimeInjection = { readonly placeholder: string readonly resolver: () => string | Promise } -export type LoadPromptInput = { - readonly source: PromptSource +export type SyncRuntimeInjection = { + readonly placeholder: string + readonly resolver: () => string +} + +export type LoadFilesystemPromptInput = { + readonly source: FilesystemPromptSource readonly name: string readonly variant: string readonly inject?: readonly RuntimeInjection[] } +export type LoadBundledPromptInput = { + readonly source: BundledPromptSource + readonly name: string + readonly variant: string + readonly inject?: readonly SyncRuntimeInjection[] +} + +export type LoadPromptInput = LoadFilesystemPromptInput | LoadBundledPromptInput + export type LoadedPrompt> = { readonly frontmatter: TFrontmatter readonly body: string