|
| 1 | +import { sprintf } from "sprintf-js"; |
| 2 | +import { z } from "zod"; |
| 3 | + |
| 4 | +import { applyCliInvocation } from "../util/invocation"; |
| 5 | +import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; |
| 6 | +import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; |
| 7 | + |
| 8 | +// Help text files embedded at build time via Vite import.meta.glob. |
| 9 | +// Filename convention: <topic>.txt for the canonical recipe and |
| 10 | +// <topic>.anonymous.txt for the local-only variant (when the flow |
| 11 | +// genuinely differs). |
| 12 | +// |
| 13 | +// This module is the single embed and the single render path for the |
| 14 | +// recipes. Both the `help` command and the `@taskless/cli/prompts` |
| 15 | +// export consume it, so the two surfaces cannot drift. It must stay |
| 16 | +// free of the CLI runtime — no citty, telemetry, filesystem, or |
| 17 | +// network — so a Worker can import the prompts entry without pulling |
| 18 | +// the command tree in behind it. |
| 19 | +const helpFiles: Record<string, string> = import.meta.glob("../help/*.txt", { |
| 20 | + query: "?raw", |
| 21 | + import: "default", |
| 22 | + eager: true, |
| 23 | +}); |
| 24 | + |
| 25 | +// Build two lookup maps: |
| 26 | +// - helpMap: "rule-create" → canonical recipe text |
| 27 | +// - anonymousMap: "rule-create" → anonymous variant text (if exists) |
| 28 | +function buildHelpMaps(): { |
| 29 | + helpMap: Map<string, string>; |
| 30 | + anonymousMap: Map<string, string>; |
| 31 | +} { |
| 32 | + const helpMap = new Map<string, string>(); |
| 33 | + const anonymousMap = new Map<string, string>(); |
| 34 | + for (const [path, content] of Object.entries(helpFiles)) { |
| 35 | + const filename = path |
| 36 | + .split("/") |
| 37 | + .pop() |
| 38 | + ?.replace(/\.txt$/, ""); |
| 39 | + if (!filename) continue; |
| 40 | + if (filename.endsWith(".anonymous")) { |
| 41 | + const topic = filename.slice(0, -".anonymous".length); |
| 42 | + anonymousMap.set(topic, content); |
| 43 | + } else { |
| 44 | + helpMap.set(filename, content); |
| 45 | + } |
| 46 | + } |
| 47 | + return { helpMap, anonymousMap }; |
| 48 | +} |
| 49 | + |
| 50 | +const { helpMap, anonymousMap } = buildHelpMaps(); |
| 51 | + |
| 52 | +/** The canonical `<topic>.txt` recipe names present in the build. */ |
| 53 | +export function canonicalRecipeTopics(): string[] { |
| 54 | + return [...helpMap.keys()]; |
| 55 | +} |
| 56 | + |
| 57 | +// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s |
| 58 | +// placeholder, the renderer substitutes the JSON Schema rendered from |
| 59 | +// this Zod source. |
| 60 | +const TOPIC_INPUT_SCHEMAS: Record<string, z.ZodType> = { |
| 61 | + "rule-create": ruleCreateInputSchema, |
| 62 | + "rule-improve": ruleImproveInputSchema, |
| 63 | +}; |
| 64 | + |
| 65 | +/** Agent-fill marker used when the caller does not supply a real value. */ |
| 66 | +const PACKAGE_MANAGER_DLX_MARKER = "<package-manager-dlx>"; |
| 67 | + |
| 68 | +/** Options accepted by the shared render path. */ |
| 69 | +export interface RecipeOptions { |
| 70 | + /** |
| 71 | + * Select the `.anonymous` variant of the topic, falling back to the |
| 72 | + * canonical recipe when the topic has no variant. |
| 73 | + * |
| 74 | + * @default false |
| 75 | + */ |
| 76 | + anonymous?: boolean; |
| 77 | + /** |
| 78 | + * Value substituted for the `%(PACKAGE_MANAGER_DLX)s` placeholder. The |
| 79 | + * default is an agent-fill marker, which is the right answer whenever |
| 80 | + * the caller does not know the consuming repo's package manager. |
| 81 | + * |
| 82 | + * @default "<package-manager-dlx>" |
| 83 | + */ |
| 84 | + packageManagerDlx?: string; |
| 85 | + /** |
| 86 | + * Include the `# Topic: <name> (CLI v<version> / topic vN)` first line. |
| 87 | + * Suppressing it drops the CLI version from the text, which matters to |
| 88 | + * an LLM consumer whose prompt-cache key would otherwise churn on every |
| 89 | + * CLI publish. |
| 90 | + * |
| 91 | + * @default true |
| 92 | + */ |
| 93 | + header?: boolean; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Render a recipe by interpolating sprintf-js named arguments. The recipe |
| 98 | + * source uses `%(KEY)s` placeholders; the variable table built here resolves |
| 99 | + * each known placeholder to its rendered string. Recipes that contain a |
| 100 | + * literal `%` character must escape it as `%%` per sprintf-js conventions. |
| 101 | + * |
| 102 | + * Two flavors of substitution coexist in the variables table: |
| 103 | + * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. |
| 104 | + * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as |
| 105 | + * `<lower-kebab-name>` so the consuming agent knows to substitute. |
| 106 | + */ |
| 107 | +function renderRecipe( |
| 108 | + content: string, |
| 109 | + topic: string, |
| 110 | + options: RecipeOptions = {} |
| 111 | +): string { |
| 112 | + const variables: Record<string, string> = { |
| 113 | + CLI_VERSION: __VERSION__, |
| 114 | + PACKAGE_MANAGER_DLX: |
| 115 | + options.packageManagerDlx ?? PACKAGE_MANAGER_DLX_MARKER, |
| 116 | + }; |
| 117 | + if (content.includes("%(INPUT_SCHEMA)s")) { |
| 118 | + const schema = TOPIC_INPUT_SCHEMAS[topic]; |
| 119 | + variables.INPUT_SCHEMA = schema |
| 120 | + ? JSON.stringify(z.toJSONSchema(schema), null, 2) |
| 121 | + : "(no input schema for this topic)"; |
| 122 | + } |
| 123 | + const rendered = sprintf(applyCliInvocation(content), variables); |
| 124 | + return options.header === false ? stripHeader(rendered) : rendered; |
| 125 | +} |
| 126 | + |
| 127 | +/** Every recipe opens with this marker on its first line. */ |
| 128 | +const HEADER_PREFIX = "# Topic:"; |
| 129 | + |
| 130 | +/** |
| 131 | + * Drop the leading header block from rendered recipe text: the `# Topic: …` |
| 132 | + * line itself plus the single blank line that separates it from the body. |
| 133 | + * Everything after that is returned untouched, so the body of a header-less |
| 134 | + * rendering is byte-identical to the default rendering's body. |
| 135 | + * |
| 136 | + * Deliberately anchored to the first line only. A `# Topic:` string later in |
| 137 | + * a recipe (inside a fenced example, say) is left alone, and a recipe that |
| 138 | + * somehow lacks the header is returned unchanged rather than losing its |
| 139 | + * first real line. |
| 140 | + */ |
| 141 | +function stripHeader(content: string): string { |
| 142 | + const firstBreak = content.indexOf("\n"); |
| 143 | + if (firstBreak === -1) { |
| 144 | + return content.startsWith(HEADER_PREFIX) ? "" : content; |
| 145 | + } |
| 146 | + if (!content.startsWith(HEADER_PREFIX)) return content; |
| 147 | + const body = content.slice(firstBreak + 1); |
| 148 | + return body.startsWith("\n") ? body.slice(1) : body; |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Look up a help topic from the embedded recipe map and return the rendered |
| 153 | + * text. Anonymous variants are preferred when `anonymous` is set and a |
| 154 | + * variant exists; otherwise the canonical recipe is returned. Returns |
| 155 | + * `undefined` when the topic is unknown. |
| 156 | + */ |
| 157 | +export function getRecipe( |
| 158 | + topic: string, |
| 159 | + options: RecipeOptions = {} |
| 160 | +): string | undefined { |
| 161 | + const content = options.anonymous |
| 162 | + ? (anonymousMap.get(topic) ?? helpMap.get(topic)) |
| 163 | + : helpMap.get(topic); |
| 164 | + if (content === undefined) return undefined; |
| 165 | + return renderRecipe(content, topic, options); |
| 166 | +} |
0 commit comments