diff --git a/src/backend.ts b/src/backend.ts index b0ab8a7..56aecd1 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -10,8 +10,6 @@ export interface PublishOpts { title?: string; /** Description (cloud backend only). */ description?: string; - /** PR number, used for gh-pages / cloudflare slug. */ - pr?: number; /** Explicit slug override. */ slug?: string; /** Owner-side tag bag (cloud only). gh-pages/cloudflare reject. */ @@ -22,6 +20,11 @@ export interface PublishOpts { * Requires at least one metadata entry. Cloud only. */ upsert?: boolean; + /** + * Free-form context for the drop — e.g. the prompt used to generate it. + * Cloud only; other backends ignore it. + */ + context?: string; } export interface UpdateOpts { diff --git a/src/backends/cloud.ts b/src/backends/cloud.ts index 64abdd3..bbe0c37 100644 --- a/src/backends/cloud.ts +++ b/src/backends/cloud.ts @@ -75,6 +75,7 @@ export function createCloudBackend(opts: CloudBackendOpts = {}): Backend { const title = po.title?.trim() || defaultTitle(filePath); const body: Parameters[0] = { html, title }; if (po.description) body.description = po.description; + if (po.context) body.context = po.context; if (po.metadata && Object.keys(po.metadata).length > 0) { body.metadata = po.metadata; } diff --git a/src/backends/cloudflare.ts b/src/backends/cloudflare.ts index cf37f85..83cee94 100644 --- a/src/backends/cloudflare.ts +++ b/src/backends/cloudflare.ts @@ -59,7 +59,7 @@ export function createCloudflareBackend(opts: CloudflareBackendOpts = {}): Backe function aliasFor(po: PublishOpts): string { if (po.slug) return po.slug.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase(); - const pr = resolvePrNumber({ explicit: po.pr }); + const pr = resolvePrNumber(); return `pr-${pr}`; } diff --git a/src/backends/gh-pages.ts b/src/backends/gh-pages.ts index da0209d..f6840be 100644 --- a/src/backends/gh-pages.ts +++ b/src/backends/gh-pages.ts @@ -52,7 +52,7 @@ export function createGhPagesBackend(opts: GhPagesBackendOpts = {}): Backend { function slugFor(po: PublishOpts): string { if (po.slug) return sanitizeSlug(po.slug); - const pr = resolvePrNumber({ explicit: po.pr }); + const pr = resolvePrNumber(); return `pr-${pr}`; } diff --git a/src/bin.ts b/src/bin.ts index ae9b5a7..843783b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,6 +10,9 @@ // Every command accepts `--to `. The active backend resolves via // the precedence in src/config.ts. +import { readFile, writeFile, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command, Option } from "commander"; import { createCloudBackend } from "./backends/cloud.js"; import { createGhPagesBackend } from "./backends/gh-pages.js"; @@ -40,6 +43,8 @@ import { listPatterns } from "./patterns/list.js"; import { initPatterns } from "./patterns/init.js"; import { ensureNoSilentSkip, installPattern } from "./patterns/install.js"; import { resolveSource } from "./patterns/sources.js"; +import { generateHtml } from "./llm/complete.js"; +import { resolvePattern } from "./llm/pattern-resolve.js"; import { updatePatterns } from "./patterns/update.js"; const VERSION = "0.2.0"; @@ -112,15 +117,17 @@ interface GlobalOpts { interface PublishCmdOpts extends GlobalOpts { title?: string; description?: string; - pr?: string; slug?: string; - repo?: string; - branch?: string; - project?: string; metadata?: string[]; upsert?: boolean; } +interface GenerateCmdOpts extends PublishCmdOpts { + prompt: string; + pattern?: string; + data?: string; +} + interface UpdateCmdOpts extends GlobalOpts { file?: string; title?: string; @@ -130,9 +137,6 @@ interface UpdateCmdOpts extends GlobalOpts { } interface ListCmdOpts extends GlobalOpts { - project?: string; - repo?: string; - branch?: string; limit?: string; metadata?: string[]; } @@ -149,23 +153,23 @@ interface SetupCmdOpts extends GlobalOpts { email?: string[]; } -async function makeBackend(name: BackendName, cfg: ConfigFile, extra: PublishCmdOpts | SetupCmdOpts = {}): Promise { +async function makeBackend(name: BackendName, cfg: ConfigFile, setup: SetupCmdOpts = {}): Promise { switch (name) { case "cloud": return createCloudBackend({ apiUrl: cfg.api_url }); case "gh-pages": return createGhPagesBackend({ - repo: extra.repo ?? cfg.repo, - branch: extra.branch ?? cfg.branch, + repo: setup.repo ?? cfg.repo, + branch: setup.branch ?? cfg.branch, }); case "cloudflare": return createCloudflareBackend({ accountId: cfg.account_id, - project: (extra as PublishCmdOpts).project ?? cfg.project, - setupIdp: (extra as SetupCmdOpts).idp, - setupEmailDomain: (extra as SetupCmdOpts).emailDomain, - setupEmail: (extra as SetupCmdOpts).email, - productionBranch: (extra as SetupCmdOpts).productionBranch, + project: setup.project ?? cfg.project, + setupIdp: setup.idp, + setupEmailDomain: setup.emailDomain, + setupEmail: setup.email, + productionBranch: setup.productionBranch, }); } } @@ -227,6 +231,39 @@ function emit(payload: unknown, renderText: () => string): void { } } +function addPublishOptions(cmd: Command): Command { + return cmd + .option("--title ", "title (cloud backend; defaults to filename)") + .option("--description ", "description (cloud backend)") + .option("--slug ", "explicit slug") + .option("--metadata ", "metadata key=value (cloud only; repeatable, up to 10)") + .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)"); +} + +function buildPublishOpts(file: string, cmdOpts: PublishCmdOpts, extra: Partial = {}): PublishOpts { + const opts: PublishOpts = { file, ...extra }; + if (cmdOpts.title) opts.title = cmdOpts.title; + if (cmdOpts.description) opts.description = cmdOpts.description; + if (cmdOpts.slug) opts.slug = cmdOpts.slug; + if (cmdOpts.metadata?.length) { + const parsed = parseMetadata(cmdOpts.metadata); + validateLocally(parsed); + opts.metadata = parsed; + } + if (cmdOpts.upsert) opts.upsert = true; + return opts; +} + +function emitDropResult(r: { url: string; slug: string; matched?: boolean; note?: string }, backend: BackendName): void { + const payload: Record = { url: r.url, slug: r.slug, backend }; + if (r.matched !== undefined) payload.matched = r.matched; + if (r.note) payload.note = r.note; + emit(payload, () => { + if (r.note) process.stderr.write(`note: ${r.note}\n`); + return r.url + "\n"; + }); +} + async function run(): Promise { const program = new Command(); program @@ -272,62 +309,75 @@ async function run(): Promise { }); // --- publish --- - program - .command("publish") - .description("Publish an HTML file and print the resulting URL") - .argument("", "path to an HTML file") - .option("--title ", "title (cloud backend; defaults to filename)") - .option("--description ", "description (cloud backend)") - .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") - .option("--slug ", "explicit slug (e.g. feature/X; overrides --pr)") - .option("--repo ", "repo (gh-pages; default: git remote origin)") - .option("--branch ", "branch (gh-pages; default: gh-pages)") - .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") - .option( - "--metadata ", - "metadata key=value (cloud only; repeatable, up to 10)" - ) - .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)") - .action(async (file: string, cmdOpts: PublishCmdOpts) => { - try { - const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); - const opts: PublishOpts = { file }; - if (cmdOpts.title) opts.title = cmdOpts.title; - if (cmdOpts.description) opts.description = cmdOpts.description; - if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); - if (cmdOpts.slug) opts.slug = cmdOpts.slug; - if (cmdOpts.metadata?.length) { - const parsed = parseMetadata(cmdOpts.metadata); - validateLocally(parsed); - opts.metadata = parsed; - } - if (cmdOpts.upsert) opts.upsert = true; - const r = await be.publish(opts); - const payload: Record = { - url: r.url, - slug: r.slug, - backend, - }; - if (r.matched !== undefined) payload.matched = r.matched; - if (r.note) payload.note = r.note; - emit(payload, () => { - let out = r.url + "\n"; - if (r.note) process.stderr.write(`note: ${r.note}\n`); - return out; + addPublishOptions( + program + .command("publish") + .description("Publish an HTML file and print the resulting URL") + .argument("", "path to an HTML file") + ).action(async (file: string, cmdOpts: PublishCmdOpts) => { + try { + const { backend, config } = await resolveActiveBackend(program.opts()); + const be = await makeBackend(backend, config); + const r = await be.publish(buildPublishOpts(file, cmdOpts)); + emitDropResult(r, backend); + } catch (e) { + die(e); + } + }); + + // --- generate --- + addPublishOptions( + program + .command("generate") + .description("Generate an HTML page from a prompt and publish it, returning a URL") + .requiredOption("--prompt ", "what to generate") + .option("--pattern ", "pattern to use as generation guide (default: auto-detected from prompt triggers)") + .option("--data ", "file whose contents are appended to the prompt (CSV, JSON, text)") + ).action(async (cmdOpts: GenerateCmdOpts) => { + try { + const { backend, config } = await resolveActiveBackend(program.opts()); + if (backend !== "cloud") { + throw new CliError( + "invalid_arg", + "generate is only supported on the cloud backend." + ); + } + const be = await makeBackend(backend, config); + + let data: string | undefined; + if (cmdOpts.data) { + data = await readFile(cmdOpts.data, "utf8").catch(() => { + throw new CliError("file_not_found", `Cannot read data file: ${cmdOpts.data}`); }); - } catch (e) { - die(e); } - }); + + const pattern = await resolvePattern({ name: cmdOpts.pattern, prompt: cmdOpts.prompt }); + if (pattern && OUTPUT_MODE === "text") { + process.stderr.write(`pattern: ${pattern.name}\n`); + } + + const html = await generateHtml(cmdOpts.prompt, data, pattern ?? undefined); + + const tmp = join(tmpdir(), `htmlbin-generate-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); + await writeFile(tmp, html, "utf8"); + + let r; + try { + r = await be.publish(buildPublishOpts(tmp, cmdOpts, { context: cmdOpts.prompt })); + } finally { + await unlink(tmp).catch(() => {}); + } + + emitDropResult(r, backend); + } catch (e) { + die(e); + } + }); // --- list --- program .command("list") .description("List published drops on the active backend") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") .option("-n, --limit ", "max rows to return (default: all)") .option( "--metadata ", @@ -336,7 +386,7 @@ async function run(): Promise { .action(async (cmdOpts: ListCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); const listOpts: ListOpts = {}; if (cmdOpts.metadata?.length) { const parsed = parseMetadata(cmdOpts.metadata); @@ -414,15 +464,12 @@ async function run(): Promise { // --- delete --- program .command("delete") - .description("Delete a drop (slug or PR number)") - .argument("", "slug or PR number") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") - .action(async (slug: string, cmdOpts: PublishCmdOpts) => { + .description("Delete a drop") + .argument("", "slug to delete") + .action(async (slug: string) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); await be.delete(slug); emit({ deleted: true, slug, backend }, () => `deleted ${slug}\n`); } catch (e) { @@ -434,14 +481,11 @@ async function run(): Promise { program .command("url") .description("Print the URL for a given slug (no publish)") - .argument("", "slug or PR number") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") - .action(async (slug: string, cmdOpts: PublishCmdOpts) => { + .argument("", "slug") + .action(async (slug: string) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); const url = await be.url(slug); emit({ url, slug, backend }, () => url + "\n"); } catch (e) { diff --git a/src/errors.ts b/src/errors.ts index 68ec37a..8f7faa4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -50,6 +50,8 @@ export type CliErrorCode = | "cloudflare_access_not_enabled" | "pr_required" | "network_error" + | "no_llm_provider" + | "llm_error" | "unknown"; export class CliError extends Error { @@ -119,7 +121,10 @@ export function exitCodeFor(code: CliErrorCode): number { return 7; case "network_error": case "server_misconfigured": + case "llm_error": return 8; + case "no_llm_provider": + return 9; default: return 1; } diff --git a/src/gh/repo.ts b/src/gh/repo.ts index d6b8d23..15fbaa4 100644 --- a/src/gh/repo.ts +++ b/src/gh/repo.ts @@ -6,9 +6,8 @@ // 3. error // // PR resolution: -// 1. --pr N CLI flag -// 2. $GITHUB_REF (refs/pull//merge|head) in GitHub Actions -// 3. error +// 1. $GITHUB_REF (refs/pull//merge|head) in GitHub Actions +// 2. error import { execa } from "execa"; import { CliError } from "../errors.js"; @@ -97,15 +96,12 @@ export function detectPrFromCiEnv(env: NodeJS.ProcessEnv = process.env): number return null; } -export function resolvePrNumber(opts: { explicit?: number; env?: NodeJS.ProcessEnv }): number { - if (typeof opts.explicit === "number" && Number.isFinite(opts.explicit) && opts.explicit > 0) { - return Math.floor(opts.explicit); - } - const fromCi = detectPrFromCiEnv(opts.env); +export function resolvePrNumber(env: NodeJS.ProcessEnv = process.env): number { + const fromCi = detectPrFromCiEnv(env); if (fromCi !== null) return fromCi; throw new CliError( "pr_required", "PR number required for this backend.", - { hint: "Pass --pr , or run under GitHub Actions where $GITHUB_REF is set on PR events." } + { hint: "Run under GitHub Actions (GITHUB_REF is set automatically on PR events), or pass --slug to set the slug explicitly." } ); } diff --git a/src/llm/complete.ts b/src/llm/complete.ts new file mode 100644 index 0000000..c093bd7 --- /dev/null +++ b/src/llm/complete.ts @@ -0,0 +1,83 @@ +import { CliError } from "../errors.js"; +import { resolveProvider } from "./provider.js"; +import type { ResolvedPattern } from "./pattern-resolve.js"; + +const BASE_SYSTEM = + "You are an HTML generator. Return only a complete, valid HTML document. " + + "No markdown. No code fences. No explanation. " + + "Start immediately with ."; + +export async function generateHtml( + prompt: string, + data?: string, + pattern?: ResolvedPattern +): Promise { + const { baseURL, apiKey, model } = resolveProvider(); + + const systemContent = pattern + ? `${BASE_SYSTEM}\n\nUse the following pattern as your guide for structure, content, and quality:\n\n${pattern.body}` + : BASE_SYSTEM; + + const userContent = data ? `${prompt}\n\n${data}` : prompt; + + let res: Response; + try { + res = await fetch(`${baseURL}/chat/completions`, { + method: "POST", + signal: AbortSignal.timeout(120_000), + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: systemContent }, + { role: "user", content: userContent }, + ], + }), + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new CliError("llm_error", `LLM request failed: ${msg}`, { cause: err }); + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new CliError("llm_error", `LLM returned non-JSON response (HTTP ${res.status})`); + } + + if (!res.ok) { + const msg = (body as any)?.error?.message ?? `HTTP ${res.status}`; + throw new CliError("llm_error", `LLM provider error: ${msg}`, { + details: { status: res.status, code: (body as any)?.error?.code }, + }); + } + + const content: unknown = (body as any)?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + throw new CliError("llm_error", "LLM returned an empty or unexpected response shape"); + } + + return extractHtml(content); +} + +function extractHtml(raw: string): string { + // Strip markdown code fences — some models add them despite instructions. + const stripped = raw + .replace(/^```(?:html)?\r?\n?/i, "") + .replace(/\r?\n?```\s*$/, "") + .trim(); + + if (!stripped.toLowerCase().startsWith(" { + let files: string[]; + try { + files = await readdir(dir); + } catch { + return []; + } + const out: ResolvedPattern[] = []; + for (const f of files) { + if (!f.endsWith(".md")) continue; + const path = join(dir, f); + try { + if (!(await stat(path)).isFile()) continue; + const raw = await readFile(path, "utf8"); + const parsed = parseAndValidatePattern(raw); + out.push({ + name: parsed.frontmatter.name, + body: parsed.body, + triggers: parsed.frontmatter.triggers, + }); + } catch { + // skip malformed files silently — listing tolerates them too + } + } + return out; +} + +async function allPatterns(): Promise> { + const byName = new Map(); + + // Precedence: bundled < global < project — later writes win. + for (const p of BUNDLED_PATTERNS) { + try { + const parsed = parseAndValidatePattern(p.body); + byName.set(parsed.frontmatter.name, { + name: parsed.frontmatter.name, + body: parsed.body, + triggers: parsed.frontmatter.triggers, + }); + } catch { + // skip invalid bundled entry + } + } + for (const p of await patternsFromDir(globalPatternsDir())) { + byName.set(p.name, p); + } + for (const p of await patternsFromDir(projectPatternsDir())) { + byName.set(p.name, p); + } + + return byName; +} + +export async function resolvePattern(opts: { + name?: string; + prompt: string; +}): Promise { + const patterns = await allPatterns(); + + if (opts.name) { + const found = patterns.get(opts.name); + if (!found) { + throw new CliError("not_found", `Pattern "${opts.name}" not found.`, { + hint: "Run 'htmlbin patterns list' to see installed patterns, or 'htmlbin patterns init' to install the defaults.", + }); + } + return found; + } + + // Auto-detect: find the trigger with the longest match against the prompt. + // Longer trigger = more specific = wins ties. + const promptLower = opts.prompt.toLowerCase(); + let best: ResolvedPattern | null = null; + let bestLen = 0; + + for (const pattern of patterns.values()) { + for (const trigger of pattern.triggers) { + if (promptLower.includes(trigger.toLowerCase()) && trigger.length > bestLen) { + bestLen = trigger.length; + best = pattern; + } + } + } + + return best; +} diff --git a/src/llm/provider.ts b/src/llm/provider.ts new file mode 100644 index 0000000..81e0673 --- /dev/null +++ b/src/llm/provider.ts @@ -0,0 +1,31 @@ +import { CliError } from "../errors.js"; + +export interface LLMProvider { + baseURL: string; + apiKey: string; + model: string; +} + +export function resolveProvider(): LLMProvider { + const baseURL = process.env.HTMLBIN_LLM_BASE_URL?.trim(); + const model = process.env.HTMLBIN_LLM_MODEL?.trim(); + + if (!baseURL || !model) { + throw new CliError( + "no_llm_provider", + "HTMLBIN_LLM_BASE_URL and HTMLBIN_LLM_MODEL must be set to use generate.", + { + hint: "Any OpenAI-compatible endpoint works. Example:\n" + + " HTMLBIN_LLM_BASE_URL=https://api.openai.com/v1\n" + + " HTMLBIN_LLM_MODEL=gpt-4o-mini\n" + + " HTMLBIN_LLM_API_KEY=", + } + ); + } + + return { + baseURL: baseURL.replace(/\/$/, ""), + apiKey: process.env.HTMLBIN_LLM_API_KEY ?? "", + model, + }; +} diff --git a/test/e2e/smoke.test.ts b/test/e2e/smoke.test.ts index 2efbba9..e3e25af 100644 --- a/test/e2e/smoke.test.ts +++ b/test/e2e/smoke.test.ts @@ -21,7 +21,7 @@ describe.skipIf(!distAvailable())("CLI smoke", () => { it("--help lists every top-level command", async () => { const r = await runCli(["--help"]); expect(r.exitCode).toBe(0); - for (const cmd of ["publish", "list", "delete", "url", "login", "setup", "patterns"]) { + for (const cmd of ["publish", "generate", "list", "delete", "url", "login", "setup", "patterns"]) { expect(r.stdout).toContain(cmd); } }); diff --git a/test/llm.test.ts b/test/llm.test.ts new file mode 100644 index 0000000..b707217 --- /dev/null +++ b/test/llm.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { resolveProvider } from "../src/llm/provider.js"; +import { resolvePattern } from "../src/llm/pattern-resolve.js"; +import { CliError } from "../src/errors.js"; + +afterEach(() => { + delete process.env.HTMLBIN_LLM_BASE_URL; + delete process.env.HTMLBIN_LLM_MODEL; + delete process.env.HTMLBIN_LLM_API_KEY; +}); + +describe("resolveProvider", () => { + it("throws no_llm_provider when env vars are missing", () => { + try { + resolveProvider(); + expect.fail("should have thrown"); + } catch (e) { + expect((e as CliError).code).toBe("no_llm_provider"); + } + }); + + it("throws when only base URL is set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + expect(() => resolveProvider()).toThrow(CliError); + }); + + it("throws when only model is set", () => { + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + expect(() => resolveProvider()).toThrow(CliError); + }); + + it("resolves when both URL and model are set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + const p = resolveProvider(); + expect(p.baseURL).toBe("https://api.openai.com/v1"); + expect(p.model).toBe("gpt-4o-mini"); + expect(p.apiKey).toBe(""); + }); + + it("includes api key when set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + process.env.HTMLBIN_LLM_API_KEY = "sk-test"; + const p = resolveProvider(); + expect(p.apiKey).toBe("sk-test"); + }); + + it("strips trailing slash from base URL", () => { + process.env.HTMLBIN_LLM_BASE_URL = "http://localhost:11434/v1/"; + process.env.HTMLBIN_LLM_MODEL = "llama3.2"; + const p = resolveProvider(); + expect(p.baseURL).toBe("http://localhost:11434/v1"); + }); + + it("works with Ollama-style local endpoint and no key", () => { + process.env.HTMLBIN_LLM_BASE_URL = "http://localhost:11434/v1"; + process.env.HTMLBIN_LLM_MODEL = "llama3.2"; + const p = resolveProvider(); + expect(p.apiKey).toBe(""); + }); +}); + +describe("resolvePattern", () => { + it("returns null when prompt matches no trigger", async () => { + const result = await resolvePattern({ prompt: "build me something completely unrelated xyzzy" }); + expect(result).toBeNull(); + }); + + it("auto-detects pr-explainer from a matching prompt", async () => { + const result = await resolvePattern({ prompt: "explain this pr to the team" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("pr-explainer"); + }); + + it("auto-detects plan-spec-explainer from a matching prompt", async () => { + const result = await resolvePattern({ prompt: "publish this plan for review" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("plan-spec-explainer"); + }); + + it("resolves explicit --pattern by name", async () => { + const result = await resolvePattern({ name: "pr-explainer", prompt: "anything" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("pr-explainer"); + }); + + it("throws not_found for an unknown explicit pattern name", async () => { + try { + await resolvePattern({ name: "no-such-pattern", prompt: "anything" }); + expect.fail("should have thrown"); + } catch (e) { + expect((e as CliError).code).toBe("not_found"); + } + }); + + it("resolved pattern has a non-empty body", async () => { + const result = await resolvePattern({ prompt: "explain this pr" }); + expect(result!.body.length).toBeGreaterThan(0); + }); +}); + +describe("exitCodeFor llm codes", () => { + it("maps no_llm_provider to 9", async () => { + const { exitCodeFor } = await import("../src/errors.js"); + expect(exitCodeFor("no_llm_provider")).toBe(9); + }); + + it("maps llm_error to 8", async () => { + const { exitCodeFor } = await import("../src/errors.js"); + expect(exitCodeFor("llm_error")).toBe(8); + }); +}); diff --git a/test/repo.test.ts b/test/repo.test.ts index b85b29b..890b86d 100644 --- a/test/repo.test.ts +++ b/test/repo.test.ts @@ -107,19 +107,11 @@ describe("detectPrFromCiEnv", () => { }); describe("resolvePrNumber", () => { - it("prefers explicit", () => { - expect(resolvePrNumber({ explicit: 7, env: { GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv })).toBe(7); + it("reads from GITHUB_REF", () => { + expect(resolvePrNumber({ GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv)).toBe(99); }); - it("falls back to env", () => { - expect(resolvePrNumber({ env: { GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv })).toBe(99); - }); - - it("throws when neither provided", () => { - expect(() => resolvePrNumber({ env: {} as NodeJS.ProcessEnv })).toThrow(CliError); - }); - - it("rejects non-positive explicit", () => { - expect(() => resolvePrNumber({ explicit: 0, env: {} as NodeJS.ProcessEnv })).toThrow(CliError); + it("throws when env has no PR ref", () => { + expect(() => resolvePrNumber({} as NodeJS.ProcessEnv)).toThrow(CliError); }); });