Skip to content

Commit cbe51d7

Browse files
thecodedriftclaude
andcommitted
ref(cli): move the recipe glob and renderer into src/prompts
`commands/help.ts` owned the `import.meta.glob` over `help/*.txt`, the canonical/anonymous map build, the `TOPIC_INPUT_SCHEMAS` table, and `renderRecipe`. That put the recipe source and its interpolation behind a citty command, so nothing else could reach the text without duplicating it. Move all of it to `src/prompts/recipes.ts` and have `help` and `onboard` call `getRecipe`. The module carries no CLI runtime: embedded text, `sprintf-js`, `applyCliInvocation`, and the two leaf Zod input schemas. Pure refactor. `help` output is byte-identical, verified across 38 captures covering all 18 canonical topics, their `--anonymous` variants, the topic index, and the unknown-topic error path, with a zero-byte diff before and after. Unit 1 of 2 for export-knowledge-prompts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ee915a commit cbe51d7

4 files changed

Lines changed: 180 additions & 106 deletions

File tree

openspec/changes/export-knowledge-prompts/tasks.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
## 1. Shared prompts module
22

3-
- [ ] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps
4-
- [ ] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path
5-
- [ ] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record<PromptTopic, (options?: PromptOptions) => string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous
6-
- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does
7-
- [ ] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only
8-
- [ ] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical
3+
- [x] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps
4+
- [x] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path
5+
- [x] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record<PromptTopic, (options?: PromptOptions) => string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous
6+
- [x] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does
7+
- [x] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only
8+
- [x] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical
99

1010
## 2. Package export + build
1111

packages/cli/src/commands/help.ts

Lines changed: 7 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,9 @@ import {
66
type Resolvable,
77
type SubCommandsDef,
88
} from "citty";
9-
import { sprintf } from "sprintf-js";
10-
import { z } from "zod";
119

1210
import { getTelemetry } from "../telemetry";
13-
import { applyCliInvocation } from "../util/invocation";
14-
import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create";
15-
import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve";
16-
17-
// Help text files embedded at build time via Vite import.meta.glob.
18-
// Filename convention: <topic>.txt for the canonical recipe and
19-
// <topic>.anonymous.txt for the local-only variant (when the flow
20-
// genuinely differs).
21-
const helpFiles: Record<string, string> = import.meta.glob("../help/*.txt", {
22-
query: "?raw",
23-
import: "default",
24-
eager: true,
25-
});
26-
27-
// Build two lookup maps:
28-
// - helpMap: "rule-create" → canonical recipe text
29-
// - anonymousMap: "rule-create" → anonymous variant text (if exists)
30-
function buildHelpMaps(): {
31-
helpMap: Map<string, string>;
32-
anonymousMap: Map<string, string>;
33-
} {
34-
const helpMap = new Map<string, string>();
35-
const anonymousMap = new Map<string, string>();
36-
for (const [path, content] of Object.entries(helpFiles)) {
37-
const filename = path
38-
.split("/")
39-
.pop()
40-
?.replace(/\.txt$/, "");
41-
if (!filename) continue;
42-
if (filename.endsWith(".anonymous")) {
43-
const topic = filename.slice(0, -".anonymous".length);
44-
anonymousMap.set(topic, content);
45-
} else {
46-
helpMap.set(filename, content);
47-
}
48-
}
49-
return { helpMap, anonymousMap };
50-
}
51-
52-
const { helpMap, anonymousMap } = buildHelpMaps();
11+
import { getRecipe } from "../prompts/recipes";
5312

5413
// Help-only recipe topics (no backing subcommand) that should still be
5514
// discoverable from the `taskless help` index. The rule-authoring front
@@ -61,56 +20,6 @@ const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [
6120
["remote", "Generate a rule via the Taskless service (login)"],
6221
];
6322

64-
// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s
65-
// placeholder, the help command substitutes the JSON Schema rendered
66-
// from this Zod source.
67-
const TOPIC_INPUT_SCHEMAS: Record<string, z.ZodType> = {
68-
"rule-create": ruleCreateInputSchema,
69-
"rule-improve": ruleImproveInputSchema,
70-
};
71-
72-
/**
73-
* Render a recipe by interpolating sprintf-js named arguments. The recipe
74-
* source uses `%(KEY)s` placeholders; the variable table built here resolves
75-
* each known placeholder to its rendered string. Recipes that contain a
76-
* literal `%` character must escape it as `%%` per sprintf-js conventions.
77-
*
78-
* Two flavors of substitution coexist in the variables table:
79-
* - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value.
80-
* - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as
81-
* `<lower-kebab-name>` so the consuming agent knows to substitute.
82-
*/
83-
function renderRecipe(content: string, topic: string): string {
84-
const variables: Record<string, string> = {
85-
CLI_VERSION: __VERSION__,
86-
PACKAGE_MANAGER_DLX: "<package-manager-dlx>",
87-
};
88-
if (content.includes("%(INPUT_SCHEMA)s")) {
89-
const schema = TOPIC_INPUT_SCHEMAS[topic];
90-
variables.INPUT_SCHEMA = schema
91-
? JSON.stringify(z.toJSONSchema(schema), null, 2)
92-
: "(no input schema for this topic)";
93-
}
94-
return sprintf(applyCliInvocation(content), variables);
95-
}
96-
97-
/**
98-
* Look up a help topic from the embedded recipe map and return the rendered
99-
* text. Anonymous variants are preferred when `anonymous` is set and a
100-
* variant exists; otherwise the canonical recipe is returned. Returns
101-
* `undefined` when the topic is unknown.
102-
*/
103-
export function getRecipe(
104-
topic: string,
105-
options: { anonymous?: boolean } = {}
106-
): string | undefined {
107-
const content = options.anonymous
108-
? (anonymousMap.get(topic) ?? helpMap.get(topic))
109-
: helpMap.get(topic);
110-
if (content === undefined) return undefined;
111-
return renderRecipe(content, topic);
112-
}
113-
11423
async function unwrap<T>(resolvable: Resolvable<T>): Promise<T> {
11524
if (typeof resolvable === "function") {
11625
return (resolvable as () => T | Promise<T>)();
@@ -213,16 +122,16 @@ export function createHelpCommand(subCommands: SubCommandsDef) {
213122
const key = positionals.join("-");
214123

215124
// Anonymous variant lookup: prefer <topic>.anonymous.txt when
216-
// --anonymous is set, fall back to the canonical recipe.
217-
const content = args.anonymous
218-
? (anonymousMap.get(key) ?? helpMap.get(key))
219-
: helpMap.get(key);
125+
// --anonymous is set, fall back to the canonical recipe. The lookup and
126+
// the render both live in the shared prompts module, so `help` and the
127+
// `@taskless/cli/prompts` export emit the same text.
128+
const recipe = getRecipe(key, { anonymous: args.anonymous });
220129

221-
if (content) {
130+
if (recipe) {
222131
// cli_help: agent fetched a specific recipe (intent signal). The topic
223132
// is the served topic; filtering on it replaces the old per-topic events.
224133
telemetry.capture("cli_help", { topic: positionals.join(" ") });
225-
console.log(renderRecipe(content, key).trimEnd());
134+
console.log(recipe.trimEnd());
226135
} else {
227136
// cli_help for an unknown topic — still the attempted topic string.
228137
telemetry.capture("cli_help", { topic: positionals.join(" ") });

packages/cli/src/commands/onboard.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { defineCommand } from "citty";
44

55
import { ensureTasklessDirectory } from "../filesystem/directory";
66
import { readManifest, writeManifest } from "../filesystem/migrate";
7+
import { getRecipe } from "../prompts/recipes";
78
import { getTelemetry } from "../telemetry";
89
import { CLIError } from "../util/cli-error";
910

10-
import { getRecipe } from "./help";
11-
1211
/**
1312
* One-line trailer printed by `taskless init` (and the wizard) after a
1413
* successful install. Lives here so the install paths share the same
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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

Comments
 (0)