From c44a6bdb2a49949e47913dee5dfdbb4b6203f27d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 24 May 2026 17:17:23 +0900 Subject: [PATCH] feat(prompts-core): add prompt loader Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__test_fixtures__/test-prompt/default.md | 6 + .../src/__test_fixtures__/test-prompt/gpt.md | 3 + packages/prompts-core/src/index.ts | 1 + packages/prompts-core/src/loader.test.ts | 112 ++++++++++++++++++ packages/prompts-core/src/loader.ts | 61 ++++++++++ 5 files changed, 183 insertions(+) create mode 100644 packages/prompts-core/src/__test_fixtures__/test-prompt/default.md create mode 100644 packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md create mode 100644 packages/prompts-core/src/loader.test.ts create mode 100644 packages/prompts-core/src/loader.ts diff --git a/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md b/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md new file mode 100644 index 000000000..4e4b14a3e --- /dev/null +++ b/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md @@ -0,0 +1,6 @@ +--- +title: Test Prompt +enabled: true +--- +Default prompt body with {X}. +Second line remains verbatim. diff --git a/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md b/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md new file mode 100644 index 000000000..ff3925c5c --- /dev/null +++ b/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md @@ -0,0 +1,3 @@ +--- +--- +GPT prompt body with {A} and {B}. diff --git a/packages/prompts-core/src/index.ts b/packages/prompts-core/src/index.ts index d5b68ec06..af788116b 100644 --- a/packages/prompts-core/src/index.ts +++ b/packages/prompts-core/src/index.ts @@ -8,3 +8,4 @@ export type { } from "./types" export { resolveVariant } from "./variant-resolver" export type { ResolveVariantInput } from "./variant-resolver" +export { loadPrompt, PromptFileNotFoundError } from "./loader" diff --git a/packages/prompts-core/src/loader.test.ts b/packages/prompts-core/src/loader.test.ts new file mode 100644 index 000000000..4017e1969 --- /dev/null +++ b/packages/prompts-core/src/loader.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { loadPrompt, PromptFileNotFoundError } from "./loader" +import type { PromptSource } from "./types" + +const fixtureSource: PromptSource = { + baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"), +} + +class ResolverFailureError extends Error { + readonly name = "ResolverFailureError" +} + +class ExpectedErrorMissingError extends Error { + readonly name = "ExpectedErrorMissingError" +} + +describe("loadPrompt", () => { + test("#given markdown fixture #then returns markdown body verbatim", async () => { + const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "default" }) + + expect(prompt.body).toBe("Default prompt body with {X}.\nSecond line remains verbatim.\n") + }) + + test("#given frontmatter fixture #then returns parsed frontmatter", async () => { + const prompt = await loadPrompt<{ readonly title: string; readonly enabled: boolean }>({ + source: fixtureSource, + name: "test-prompt", + variant: "default", + }) + + expect(prompt.frontmatter.title).toBe("Test Prompt") + expect(prompt.frontmatter.enabled).toBe(true) + }) + + test("#given empty frontmatter #then parses without crashing", async () => { + const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "gpt" }) + + expect(prompt.frontmatter).toEqual({}) + expect(prompt.body).toBe("GPT prompt body with {A} and {B}.\n") + }) + + test("#given missing file #then error mentions prompt name and variant", async () => { + const error = await captureError(() => + loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "missing" }) + ) + + expect(error).toBeInstanceOf(PromptFileNotFoundError) + expect(expectError(error).message).toContain("test-prompt/missing") + }) + + test("#given runtime injection #then replaces placeholder in body", async () => { + const prompt = await loadPrompt({ + source: fixtureSource, + name: "test-prompt", + variant: "default", + inject: [{ placeholder: "{X}", resolver: () => "Y" }], + }) + + expect(prompt.body).toBe("Default prompt body with Y.\nSecond line remains verbatim.\n") + }) + + test("#given multiple runtime injections #then applies all and ignores absent placeholders", async () => { + const prompt = await loadPrompt({ + source: fixtureSource, + name: "test-prompt", + variant: "gpt", + inject: [ + { placeholder: "{A}", resolver: () => "Alpha" }, + { placeholder: "{B}", resolver: () => "Beta" }, + { placeholder: "{ABSENT}", resolver: () => "No-op" }, + ], + }) + + expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n") + }) + + test("#given injection resolver throws #then propagates the error", async () => { + const error = await captureError(() => + loadPrompt({ + source: fixtureSource, + name: "test-prompt", + variant: "default", + inject: [ + { + placeholder: "{X}", + resolver: () => { + throw new ResolverFailureError("resolver failed") + }, + }, + ], + }) + ) + + expect(error).toBeInstanceOf(ResolverFailureError) + }) +}) + +async function captureError(operation: () => Promise): Promise { + try { + await operation() + return undefined + } catch (error) { + return error + } +} + +function expectError(error: unknown): Error { + if (error instanceof Error) return error + throw new ExpectedErrorMissingError("Expected operation to throw an Error instance") +} diff --git a/packages/prompts-core/src/loader.ts b/packages/prompts-core/src/loader.ts new file mode 100644 index 000000000..6d9a04218 --- /dev/null +++ b/packages/prompts-core/src/loader.ts @@ -0,0 +1,61 @@ +import { parseFrontmatter } from "@oh-my-opencode/utils" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types" + +export class PromptFileNotFoundError extends Error { + readonly name = "PromptFileNotFoundError" + + constructor( + readonly promptName: string, + readonly variant: string, + readonly filePath: string, + options?: ErrorOptions + ) { + super(`Prompt file not found for ${promptName}/${variant}: ${filePath}`, options) + } +} + +export async function loadPrompt>( + input: LoadPromptInput +): Promise> { + const filePath = join(input.source.baseDir, input.name, `${input.variant}.md`) + const content = await readPromptFile(input.name, input.variant, filePath) + const parsed = parseFrontmatter(content) + const body = await applyRuntimeInjections(parsed.body, input.inject ?? []) + + return { + frontmatter: parsed.data, + body, + hadFrontmatter: parsed.hadFrontmatter, + parseError: parsed.parseError, + filePath, + } +} + +async function readPromptFile(promptName: string, variant: string, filePath: string): Promise { + try { + return await readFile(filePath, "utf8") + } catch (error) { + if (error instanceof Error && getErrorCode(error) === "ENOENT") { + throw new PromptFileNotFoundError(promptName, variant, filePath, { cause: error }) + } + throw error + } +} + +async function applyRuntimeInjections( + body: string, + injections: readonly RuntimeInjection[] +): Promise { + let renderedBody = body + for (const injection of injections) { + renderedBody = renderedBody.replaceAll(injection.placeholder, await injection.resolver()) + } + return renderedBody +} + +function getErrorCode(error: Error): string | undefined { + if (!("code" in error)) return undefined + return typeof error.code === "string" ? error.code : undefined +}