diff --git a/openspec/changes/export-knowledge-prompts/.openspec.yaml b/openspec/changes/export-knowledge-prompts/.openspec.yaml new file mode 100644 index 00000000..8e7013b8 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md new file mode 100644 index 00000000..59098fa3 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -0,0 +1,112 @@ +## Context + +The CLI's knowledge prompts live at `packages/cli/src/help/*.txt` — canonical `.txt` recipes (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`, `check`, `rule-create`, …) plus `.anonymous.txt` local-only variants. `commands/help.ts` embeds them at build time with `import.meta.glob("../help/*.txt", { query: "?raw", import: "default", eager: true })`, builds `helpMap`/`anonymousMap`, and — for some topics — interpolates `%(KEY)s` placeholders (e.g. `CLI_VERSION`, `INPUT_SCHEMA`, agent-fill markers) via `sprintf-js` at render time. + +The package ships only `dist` and exports only `"." → ./dist/index.js` (which pulls the whole command tree: `citty`, `zod`, telemetry, …). So the prompts are reachable only by running `taskless help ` — not importable. `workers/generator` (a Cloudflare Worker) needs the same guidance programmatically, and must be able to import it **without** dragging in the CLI runtime. + +## Goals / Non-Goals + +**Goals:** expose the knowledge prompts as a stable, typed, importable API (`@taskless/cli/prompts`); keep one source of truth shared with the `help` command — the same embedded text _and_ the same render pipeline, so both surfaces emit identical output; keep the import free of the CLI runtime so a Worker can consume it. + +**Non-Goals:** exposing a caller-facing template/render API (`renderPrompt(topic, vars)`); changing the `help` command's observable behavior or the recipe text; changing rule execution or on-disk formats. + +## Decisions + +### D1 — A shared prompts module is the single source; `help.ts` consumes it + +Move the glob + `buildHelpMaps` logic **and `renderRecipe`** (`commands/help.ts:83-95`) into `src/prompts/recipes.ts`; `help.ts` imports from it. One embed and one render path, no duplication. `src/prompts/index.ts` sits on top of that module as the public entry, so the shared implementation and the published surface are separately reviewable. + +- **Alternative — a second glob in `help.ts` and the export:** rejected; two embeds drift. +- **Alternative — share the embed but not the renderer:** rejected; the two surfaces would emit different text from identical source, which is the drift the change exists to prevent. + +### D2 — The export carries no CLI runtime, imported via a subpath + +The `src/prompts/` graph (`index.ts` and the `recipes.ts` it consumes) imports nothing from the CLI runtime — no `citty`/command tree, no telemetry, no filesystem or network. It may import `sprintf-js` and the two Zod input schemas (`schemas/rules-create`, `schemas/rules-improve`), which are leaf modules whose only dependency is `zod`; both are already dependencies of the intended consumer. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. + +- **Alternative — re-export from the main entry (`@taskless/cli`):** rejected; the main entry pulls the whole CLI, unusable/heavy in a Worker. +- **Alternative — pure data with zero deps, pre-rendering `INPUT_SCHEMA` at build time:** rejected as premature; it buys nothing for the intended consumer (which already ships `zod`) and adds a codegen step to keep in sync. + +### D3 — Prompts are functions returning fully-rendered text + +Each prompt is a function; calling it runs the same pipeline `taskless help` runs — `applyCliInvocation`, then `sprintf` over the variable table — and returns finished text. A consumer never observes a `%(KEY)s` placeholder. + +Every placeholder in use today is resolvable **inside** the package, so there is nothing for a caller to supply: + +| Placeholder | Topics | Resolved from | +| --------------------- | ----------------------------- | --------------------------------------------------------------- | +| `CLI_VERSION` | all (header line) | build define `__VERSION__` | +| `INPUT_SCHEMA` | `rule-create`, `rule-improve` | `z.toJSONSchema()` over the Zod input schema | +| `PACKAGE_MANAGER_DLX` | `ci` | agent-fill marker `` (overridable, see D4) | + +No topic the first consumer needs contains anything but `CLI_VERSION`. (An earlier draft of this design named a six-topic consumer set read off the `generator-decision-router` design; the `cloud` side has since established that only `static` is reachable server-side — see D6.) + +Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. That is a no-op for prod builds, and the first consumer will consume a normal published release (D6), so this is not load-bearing for that consumer. It still matters for parity in general: any consumer resolving the package from a non-prod build gets the same invocation string `taskless help` prints, because both go through one render path. + +- **Alternative — return raw text, placeholders intact:** rejected; pushes an undocumented template dialect (sprintf-js, including its `%%` escaping rule) onto every consumer to solve values the package already knows. +- **Alternative — a caller-facing `renderPrompt(topic, vars)`:** rejected as speculative; there is no variable a caller knows and the package does not. The optional-options escape hatch in D4 covers the case non-breakingly if one appears. + +### D4 — Typed accessor: `PromptTopic`, `PROMPTS`, `getPrompt` + +Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. Adding a future option is additive, not breaking. + +`PromptOptions`: + +| Field | Default | Purpose | +| -------------------- | ----------------------- | --------------------------------------------------------------------------- | +| `anonymous?` | `false` | select the `.anonymous` variant, falling back to canonical when none exists | +| `packageManagerDlx?` | `` | the one value a caller may know better than the package | +| `header?` | `true` | include the `# Topic: (CLI v / topic v1)` first line | + +`header: false` exists for a concrete reason: the header embeds the CLI version, and a consumer placing rendered text in an LLM **system prompt** puts that version into the prompt-cache key — so every CLI publish invalidates the consumer's cache for every request, over a line the model does not use. Defaulting to `true` leaves `taskless help` output and every existing behavior byte-identical. + +- **Alternative — let consumers strip the header themselves:** rejected; every consumer reimplements the same fragile first-line regex against text whose format we control, and it silently breaks if the header ever gains a line. + +Topic names, the accessor shape, and `PromptOptions`' existing fields are the **public API** (semver-tracked); recipe _text_ may change within a major. + +- **Alternative — `Record` of pre-rendered strings:** rejected; it has no room for the `anonymous` dimension without doubling the key space, and forecloses per-call options. + +### D5 — `PromptTopic` is an explicit list, kept honest by a completeness check + +`PromptTopic` derives from a hand-maintained `const TOPICS = [...] as const` tuple, not from the recipe files on disk. A companion `INTERNAL_TOPICS` set records recipe files deliberately withheld from the export. A test asserts the canonical `help/*.txt` topics on disk equal `TOPICS ∪ INTERNAL_TOPICS`, failing in either direction. + +Deriving the union from the glob is not possible at the type level regardless: Vite types `import.meta.glob` as `Record` (`vite/types/importGlob.d.ts:69-70,88`) — the keys are `string`, with no literal inference from the pattern. + +It is also not desirable. Topic names are semver-tracked public API (D4), so an auto-derived union would let a new `src/help/*.txt` file silently publish public API, and a deleted one silently ship a major break. The explicit list is the gate; the check is what prevents the gate from drifting out of sync unnoticed. A new recipe file fails CI on the PR that adds it, forcing a deliberate export-or-withhold decision. + +- **Alternative — codegen `topics.generated.ts` from the glob with a CI `--check` mode:** rejected; converts the failure from a red test to a red type error, but adds a generated file plus a script, still needs the same CI gate, and reinstates silent publishing of any newly added recipe. +- **Trade-off accepted:** completeness is enforced at test time, not compile time. The check runs on the PR that introduces the divergence, which is when it matters. + +### D6 — The initial exported topic list is minimal, driven by a real consumer + +The first consumer (`generator-decision-router`, in the `cloud` repo) has confirmed which topics it can actually use server-side, and it is not the set this design originally assumed. Verified against the recipe files: + +| Topic | Server-side usable | Why not | +| ----------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `static` | **yes** | canonical on-disk rule shape; the one topic the router wants | +| `route` | no | picks an _authoring destination_ (`existing`/`static`/`remote`) pre-service; runs `detect`, asks the user. A request reaching the generator has already resolved to `remote` | +| `remote` | no | states the boundary itself — "The service owns rule-type selection" (`remote.txt:51`) | +| `detect` | no | documents a CLI subprocess a Worker cannot run | +| `existing` | no | local-toolchain authoring; structurally unreachable server-side | +| `rule-meta` | no | unrelated — reads a `rule improve` metadata sidecar | + +So `TOPICS` starts at the minimum a consumer genuinely needs and grows on demand. Under D4 an exported name is a promise held for a major version; exporting a topic speculatively spends that promise for nothing. Everything else is recorded in `INTERNAL_TOPICS` (D5), which keeps them visible and deliberate rather than forgotten. + +Consumption is via a **normal published release** of `@taskless/cli`. A workspace/path dependency is not an option — `../skills` sits outside the `cloud` pnpm workspace and does not resolve in its CI or the generator's Docker build — and the consumer has explicitly declined a prerelease, preferring to wait for a stable version rather than pin a moving one against an API that is by definition not yet semver-stable. + +**This change is therefore not on anyone's critical path.** Nothing downstream is waiting on it, so it can be released at whatever cadence suits this repo, and there is no reason to cut a prerelease to unblock a consumer. + +## Risks / Trade-offs + +- **Build must emit the second entry** → configure Vite for a `prompts` entry with types; a CI/test asserts `dist/prompts.js` + `.d.ts` exist, or the export resolves to nothing at publish. +- **Build defines must reach the second entry** → `dist/prompts.js` depends on `__VERSION__` and `__TASKLESS_CLI__` being inlined. If the `prompts` entry is configured without the same `define` block as the main entry, rendering emits a literal `__VERSION__` or throws. A test asserting rendered output contains no `__`-prefixed define names covers this. +- **No per-topic tree-shaking** → a render function isn't statically analyzable, so all 20 recipes (~66 KB of text) ship even when a consumer reads six. Negligible against Worker bundle limits; accepted deliberately in exchange for `help`/export parity. +- **Prompt text drift within a major** → acceptable and stated; only topic names + accessor shape are stability-guaranteed. + +## Migration Plan + +Purely additive: add the module, the subpath export, and the build entry; move the embed + `renderRecipe` out of `help.ts` and import them back (no observable behavior change). No consumer migration needed until `generator-decision-router` imports it. + +## Resolved Questions + +- **Interpolation (resolved: no caller-facing renderer).** Prompts are functions that return fully-rendered text; every placeholder in use resolves inside the package (D3). `PromptOptions.packageManagerDlx` is the sole caller-supplied value, and none of the first consumer's topics use it. +- **Subpath name (resolved: `@taskless/cli/prompts`).** Confirmed as the published name. It reads accurately for a surface broader than routing — it covers every `help` recipe, including `ci`, `auth`, and `init` — and matches the name the `generator-decision-router` change already references. diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md new file mode 100644 index 00000000..374b7631 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -0,0 +1,37 @@ +## Why + +The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`, `check`, …) — encode Taskless's rule-authoring and routing guidance. Today they are embedded at build time (Vite `import.meta.glob`) and reachable only through the `help` command (`npx @taskless/cli help `); the package `exports` is just `"." → dist/index.js`, so nothing can import them. `workers/generator` (and future consumers) need the _same_ guidance programmatically to give a consistent authoring/routing experience across the CLI and the service. This change exposes the prompts as a stable package import. + +## What Changes + +- Add a package **subpath export** `@taskless/cli/prompts` (built into `dist`, listed in `files`) that exposes the embedded `help/*.txt` recipes as importable, topic-keyed render functions. +- Provide a typed accessor (`getPrompt(topic, options?)` and a `PROMPTS` record of functions) plus a stable `PromptTopic` union so consumers get compile-time safety over available topics. +- Return **fully-rendered** text: calling a prompt resolves every `%(KEY)s` placeholder from values the package already holds (version, input schemas, agent-fill markers), so consumers never handle a template dialect. +- Keep the `help` command's output unchanged — it consumes the same embedded source _and_ the same renderer, so there is one source of truth for both surfaces. +- Treat the prompt export as **public API**: topic names and the accessor shape are semver-tracked; prompt _text_ may evolve within a major. Membership is an explicit list, not whatever files happen to be on disk, guarded by a completeness check so a new or deleted recipe can't silently change the published surface. + +## Capabilities + +### New Capabilities + +- `cli-knowledge-prompts`: A stable, importable API exposing the CLI's knowledge prompts (the `help/*.txt` recipes) as topic-keyed render functions with a typed accessor, sourced from the same embedded content and render path the `help` command serves. + +## Impact + +- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/` module — `recipes.ts` holding the embedded `help/*.txt` map and the render path, `index.ts` holding the typed accessor published as the subpath entry; `commands/help.ts` refactored to consume that shared module (no observable behavior change); the Vite build gains a second entry. +- **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. +- **No change** to CLI commands, rule execution, or on-disk formats. + +## Delivery shape + +**Release impact: minor.** Adds a new public subpath export (`@taskless/cli/prompts`). Purely additive — no existing surface changes — but a new entry point that consumers can depend on is a feature, not a fix. + +**Stacked, merging forward.** Each unit is independently safe: the first changes no observable behavior, and the second only adds a new export. + +| Unit | Scope | Safe alone because | +| ---- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| 1 | Move the glob and `renderRecipe` into `src/prompts/`, have `commands/help.ts` consume them | Pure refactor — `help` output must be byte-identical, which is the unit's own test | +| 2 | The `./prompts` subpath export, Vite entry, `TOPICS`/`PromptOptions` API, completeness check | Adds a new public surface; nothing existing depends on it yet | + +Unit 1 must not change `help` output at all, so a difference there is a regression rather than a judgement call. Splitting this way also means the risky part (touching a shipped command) is reviewed apart from the new API surface. +**Tracking:** OSS-20 diff --git a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..8ea23cb6 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: The package exposes knowledge prompts via a dedicated import + +The package SHALL expose its knowledge prompts (the `help/*.txt` recipes) through a subpath export `@taskless/cli/prompts`, built into `dist` and listed in `files`, so consumers can import them without invoking the CLI. + +#### Scenario: Importing a prompt by topic + +- **WHEN** a consumer imports `getPrompt` (or `PROMPTS`) from `@taskless/cli/prompts` +- **THEN** it receives the recipe text for a known topic (e.g. `route`, `static`, `runtime`-adjacent authoring recipes) as a string + +### Requirement: The prompt export is typed + +The export SHALL provide a `PromptTopic` union of the available canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. Referencing an unknown topic SHALL be a compile-time error against `PromptTopic`. + +#### Scenario: Typed access to topics + +- **WHEN** a consumer calls `getPrompt("route")` +- **THEN** it type-checks and returns the `route` recipe; `getPrompt("nope")` fails type-checking against `PromptTopic` + +#### Scenario: A topic is callable from the map + +- **WHEN** a consumer calls `PROMPTS.route()` with no arguments +- **THEN** it returns the same string as `getPrompt("route")` + +### Requirement: The export and the help command share one source and one renderer + +The prompt export SHALL be sourced from the same embedded `help/*.txt` content that `commands/help.ts` serves, and SHALL render it through the same render path, with no duplicated embedding and no duplicated interpolation logic. Both surfaces SHALL return identical text for the same topic and equivalent options. + +#### Scenario: Parity between import and help command + +- **WHEN** the `help` command renders topic `T` and a consumer calls `getPrompt("T")` +- **THEN** the two texts are identical, including under a non-prod build target where the CLI invocation is rewritten + +### Requirement: The export returns fully-rendered prompt text + +Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder substituted — `CLI_VERSION` from the build-time version, `INPUT_SCHEMA` from the corresponding Zod input schema, `PACKAGE_MANAGER_DLX` from `PromptOptions.packageManagerDlx` or its default agent-fill marker — and with the build-target CLI invocation applied. The returned text SHALL NOT require further templating by the consumer. + +#### Scenario: Placeholders are resolved + +- **WHEN** a consumer calls a prompt for a recipe whose source contains `%(CLI_VERSION)s` +- **THEN** the returned string contains the rendered version and no literal `%(...)s` placeholder + +#### Scenario: Schema-bearing topics render their input schema + +- **WHEN** a consumer calls the `rule-create` or `rule-improve` prompt +- **THEN** `%(INPUT_SCHEMA)s` is replaced by the JSON Schema rendered from that topic's Zod input schema + +#### Scenario: Agent-fill marker defaults and overrides + +- **WHEN** a consumer calls the `ci` prompt without options +- **THEN** `%(PACKAGE_MANAGER_DLX)s` renders as the default `` marker; supplying `packageManagerDlx` substitutes that value instead + +### Requirement: The version header is suppressible + +Rendered prompts SHALL begin with a header line naming the topic and the CLI version. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing it. It SHALL default to `true`, leaving the `help` command's output and all existing behavior unchanged. + +#### Scenario: Header suppressed for a cache-stable system prompt + +- **WHEN** a consumer calls a prompt with `header: false` +- **THEN** the returned text omits the `# Topic: …` line and contains no CLI version string, while the body is otherwise identical to the default rendering + +#### Scenario: Header present by default + +- **WHEN** a prompt is called with no options, or the `help` command renders a topic +- **THEN** the header line is present, exactly as it renders today + +#### Scenario: Build defines are inlined into the prompts entry + +- **WHEN** a rendered prompt is inspected from the built `dist/prompts.js` +- **THEN** it contains no un-inlined build-define identifier (e.g. a literal `__VERSION__`) + +### Requirement: The prompts import is free of CLI runtime dependencies + +The `@taskless/cli/prompts` module SHALL contain only embedded prompt data, types, and the render path — no `citty` command tree, telemetry, filesystem, or network imports — so importing it does not load `@taskless/cli`'s main entry. Its permitted runtime imports are the templating library and the leaf Zod input schemas required for rendering. + +#### Scenario: Worker-safe import + +- **WHEN** a consumer imports `@taskless/cli/prompts` +- **THEN** the module resolves without pulling in `dist/index.js` or its CLI runtime dependencies + +### Requirement: Anonymous variants are accessible distinctly from canonical + +Where a `.anonymous.txt` variant exists, the export SHALL make it retrievable distinctly via `PromptOptions.anonymous`, falling back to the canonical recipe when no variant exists. + +#### Scenario: Anonymous variant retrieval and fallback + +- **WHEN** a consumer requests the anonymous variant of a topic that has one +- **THEN** it receives the `.anonymous` text; for a topic without a variant, it receives the canonical text + +### Requirement: Topic names and accessor shape are stable public API + +The set of `PromptTopic` names, the `getPrompt`/`PROMPTS` shape, and the existing fields of `PromptOptions` SHALL be treated as public API under semver; recipe _text_ MAY change within a major version. + +#### Scenario: Removing a topic is a breaking change + +- **WHEN** a topic is removed or renamed, or the accessor signature changes +- **THEN** it SHALL be released as a major version bump; a text edit SHALL NOT + +#### Scenario: Adding an option is not a breaking change + +- **WHEN** a new optional field is added to `PromptOptions` +- **THEN** it SHALL NOT require a major version bump, since existing call sites keep their behavior + +### Requirement: Topic membership is explicit and verified against the recipe files + +`PromptTopic` SHALL be derived from an explicit, hand-maintained list of exported topics rather than inferred from whatever recipe files are present, so that adding or removing a `help/*.txt` file cannot silently change the public API. Recipe files deliberately withheld from the export SHALL be recorded in an explicit internal-topics list. + +An automated check SHALL assert that the set of canonical `help/*.txt` topics on disk is exactly the union of the exported topics and the internal-topics list, failing when the two diverge in either direction. + +#### Scenario: A new recipe file is added without being classified + +- **WHEN** a new canonical `help/.txt` is added and appears in neither the exported topics nor the internal-topics list +- **THEN** the completeness check SHALL fail, requiring the author to either export the topic or record it as internal + +#### Scenario: An exported topic loses its recipe file + +- **WHEN** a topic remains in `PromptTopic` but its canonical `help/.txt` no longer exists +- **THEN** the completeness check SHALL fail, rather than the topic rendering empty or undefined at runtime + +#### Scenario: A deliberately internal recipe stays unexported + +- **WHEN** a recipe file is listed as internal +- **THEN** the check SHALL pass and the topic SHALL NOT be a member of `PromptTopic` diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md new file mode 100644 index 00000000..f8a9e67d --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -0,0 +1,22 @@ +## 1. Shared prompts module + +- [x] 1.1 Create `packages/cli/src/prompts/recipes.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps +- [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 +- [ ] 1.3 Export the typed API from `packages/cli/src/prompts/index.ts`: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous +- [ ] 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 +- [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 +- [x] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical + +## 2. Package export + build + +- [ ] 2.1 Add the `./prompts` subpath to `package.json` `exports` (→ `./dist/prompts.js`, with `types`) and keep `files: ["dist"]` +- [ ] 2.2 Configure the Vite build to emit `dist/prompts.js` (+ `dist/prompts.d.ts`) as a second entry alongside `dist/index.js`, **with the same `define` block** (`__VERSION__`, `__TASKLESS_CLI__`) as the main entry +- [ ] 2.3 Add a build/CI assertion that `dist/prompts.js` and its types exist after `vite build` + +## 3. Verify + +- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's rendered text; every `%(KEY)s` resolved (no literal placeholder survives); `rule-create`/`rule-improve` render their JSON Schema; `ci` renders the `` default and honors an override; `header: false` drops the `# Topic:` line and leaves no version string while the body is unchanged; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics +- [ ] 3.2 Test: rendered output from the built `dist/prompts.js` contains no un-inlined build define (e.g. a literal `__VERSION__`) +- [ ] 3.3 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) +- [ ] 3.4 Completeness check: assert the canonical `help/*.txt` topics on disk equal `TOPICS ∪ INTERNAL_TOPICS` — fails both when a new recipe is unclassified and when an exported topic's file is gone +- [ ] 3.5 `pnpm --filter @taskless/cli typecheck && lint && test` clean; `vite build` emits both entries diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index b76523b6..1156f244 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -6,50 +6,9 @@ import { type Resolvable, type SubCommandsDef, } from "citty"; -import { sprintf } from "sprintf-js"; -import { z } from "zod"; import { getTelemetry } from "../telemetry"; -import { applyCliInvocation } from "../util/invocation"; -import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; -import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; - -// Help text files embedded at build time via Vite import.meta.glob. -// Filename convention: .txt for the canonical recipe and -// .anonymous.txt for the local-only variant (when the flow -// genuinely differs). -const helpFiles: Record = import.meta.glob("../help/*.txt", { - query: "?raw", - import: "default", - eager: true, -}); - -// Build two lookup maps: -// - helpMap: "rule-create" → canonical recipe text -// - anonymousMap: "rule-create" → anonymous variant text (if exists) -function buildHelpMaps(): { - helpMap: Map; - anonymousMap: Map; -} { - const helpMap = new Map(); - const anonymousMap = new Map(); - for (const [path, content] of Object.entries(helpFiles)) { - const filename = path - .split("/") - .pop() - ?.replace(/\.txt$/, ""); - if (!filename) continue; - if (filename.endsWith(".anonymous")) { - const topic = filename.slice(0, -".anonymous".length); - anonymousMap.set(topic, content); - } else { - helpMap.set(filename, content); - } - } - return { helpMap, anonymousMap }; -} - -const { helpMap, anonymousMap } = buildHelpMaps(); +import { getRecipe } from "../prompts/recipes"; // Help-only recipe topics (no backing subcommand) that should still be // discoverable from the `taskless help` index. The rule-authoring front @@ -61,56 +20,6 @@ const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [ ["remote", "Generate a rule via the Taskless service (login)"], ]; -// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s -// placeholder, the help command substitutes the JSON Schema rendered -// from this Zod source. -const TOPIC_INPUT_SCHEMAS: Record = { - "rule-create": ruleCreateInputSchema, - "rule-improve": ruleImproveInputSchema, -}; - -/** - * Render a recipe by interpolating sprintf-js named arguments. The recipe - * source uses `%(KEY)s` placeholders; the variable table built here resolves - * each known placeholder to its rendered string. Recipes that contain a - * literal `%` character must escape it as `%%` per sprintf-js conventions. - * - * Two flavors of substitution coexist in the variables table: - * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. - * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as - * `` so the consuming agent knows to substitute. - */ -function renderRecipe(content: string, topic: string): string { - const variables: Record = { - CLI_VERSION: __VERSION__, - PACKAGE_MANAGER_DLX: "", - }; - if (content.includes("%(INPUT_SCHEMA)s")) { - const schema = TOPIC_INPUT_SCHEMAS[topic]; - variables.INPUT_SCHEMA = schema - ? JSON.stringify(z.toJSONSchema(schema), null, 2) - : "(no input schema for this topic)"; - } - return sprintf(applyCliInvocation(content), variables); -} - -/** - * Look up a help topic from the embedded recipe map and return the rendered - * text. Anonymous variants are preferred when `anonymous` is set and a - * variant exists; otherwise the canonical recipe is returned. Returns - * `undefined` when the topic is unknown. - */ -export function getRecipe( - topic: string, - options: { anonymous?: boolean } = {} -): string | undefined { - const content = options.anonymous - ? (anonymousMap.get(topic) ?? helpMap.get(topic)) - : helpMap.get(topic); - if (content === undefined) return undefined; - return renderRecipe(content, topic); -} - async function unwrap(resolvable: Resolvable): Promise { if (typeof resolvable === "function") { return (resolvable as () => T | Promise)(); @@ -213,16 +122,16 @@ export function createHelpCommand(subCommands: SubCommandsDef) { const key = positionals.join("-"); // Anonymous variant lookup: prefer .anonymous.txt when - // --anonymous is set, fall back to the canonical recipe. - const content = args.anonymous - ? (anonymousMap.get(key) ?? helpMap.get(key)) - : helpMap.get(key); + // --anonymous is set, fall back to the canonical recipe. The lookup and + // the render both live in the shared prompts module, so `help` and the + // `@taskless/cli/prompts` export emit the same text. + const recipe = getRecipe(key, { anonymous: args.anonymous }); - if (content) { + if (recipe) { // cli_help: agent fetched a specific recipe (intent signal). The topic // is the served topic; filtering on it replaces the old per-topic events. telemetry.capture("cli_help", { topic: positionals.join(" ") }); - console.log(renderRecipe(content, key).trimEnd()); + console.log(recipe.trimEnd()); } else { // cli_help for an unknown topic — still the attempted topic string. telemetry.capture("cli_help", { topic: positionals.join(" ") }); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index c46b3f37..14945c7b 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -4,11 +4,10 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { readManifest, writeManifest } from "../filesystem/migrate"; +import { getRecipe } from "../prompts/recipes"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; -import { getRecipe } from "./help"; - /** * One-line trailer printed by `taskless init` (and the wizard) after a * successful install. Lives here so the install paths share the same diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts new file mode 100644 index 00000000..423e8417 --- /dev/null +++ b/packages/cli/src/prompts/recipes.ts @@ -0,0 +1,166 @@ +import { sprintf } from "sprintf-js"; +import { z } from "zod"; + +import { applyCliInvocation } from "../util/invocation"; +import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; +import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; + +// Help text files embedded at build time via Vite import.meta.glob. +// Filename convention: .txt for the canonical recipe and +// .anonymous.txt for the local-only variant (when the flow +// genuinely differs). +// +// This module is the single embed and the single render path for the +// recipes. Both the `help` command and the `@taskless/cli/prompts` +// export consume it, so the two surfaces cannot drift. It must stay +// free of the CLI runtime — no citty, telemetry, filesystem, or +// network — so a Worker can import the prompts entry without pulling +// the command tree in behind it. +const helpFiles: Record = import.meta.glob("../help/*.txt", { + query: "?raw", + import: "default", + eager: true, +}); + +// Build two lookup maps: +// - helpMap: "rule-create" → canonical recipe text +// - anonymousMap: "rule-create" → anonymous variant text (if exists) +function buildHelpMaps(): { + helpMap: Map; + anonymousMap: Map; +} { + const helpMap = new Map(); + const anonymousMap = new Map(); + for (const [path, content] of Object.entries(helpFiles)) { + const filename = path + .split("/") + .pop() + ?.replace(/\.txt$/, ""); + if (!filename) continue; + if (filename.endsWith(".anonymous")) { + const topic = filename.slice(0, -".anonymous".length); + anonymousMap.set(topic, content); + } else { + helpMap.set(filename, content); + } + } + return { helpMap, anonymousMap }; +} + +const { helpMap, anonymousMap } = buildHelpMaps(); + +/** The canonical `.txt` recipe names present in the build. */ +export function canonicalRecipeTopics(): string[] { + return [...helpMap.keys()]; +} + +// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s +// placeholder, the renderer substitutes the JSON Schema rendered from +// this Zod source. +const TOPIC_INPUT_SCHEMAS: Record = { + "rule-create": ruleCreateInputSchema, + "rule-improve": ruleImproveInputSchema, +}; + +/** Agent-fill marker used when the caller does not supply a real value. */ +const PACKAGE_MANAGER_DLX_MARKER = ""; + +/** Options accepted by the shared render path. */ +export interface RecipeOptions { + /** + * Select the `.anonymous` variant of the topic, falling back to the + * canonical recipe when the topic has no variant. + * + * @default false + */ + anonymous?: boolean; + /** + * Value substituted for the `%(PACKAGE_MANAGER_DLX)s` placeholder. The + * default is an agent-fill marker, which is the right answer whenever + * the caller does not know the consuming repo's package manager. + * + * @default "" + */ + packageManagerDlx?: string; + /** + * Include the `# Topic: (CLI v / topic vN)` first line. + * Suppressing it drops the CLI version from the text, which matters to + * an LLM consumer whose prompt-cache key would otherwise churn on every + * CLI publish. + * + * @default true + */ + header?: boolean; +} + +/** + * Render a recipe by interpolating sprintf-js named arguments. The recipe + * source uses `%(KEY)s` placeholders; the variable table built here resolves + * each known placeholder to its rendered string. Recipes that contain a + * literal `%` character must escape it as `%%` per sprintf-js conventions. + * + * Two flavors of substitution coexist in the variables table: + * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. + * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as + * `` so the consuming agent knows to substitute. + */ +function renderRecipe( + content: string, + topic: string, + options: RecipeOptions = {} +): string { + const variables: Record = { + CLI_VERSION: __VERSION__, + PACKAGE_MANAGER_DLX: + options.packageManagerDlx ?? PACKAGE_MANAGER_DLX_MARKER, + }; + if (content.includes("%(INPUT_SCHEMA)s")) { + const schema = TOPIC_INPUT_SCHEMAS[topic]; + variables.INPUT_SCHEMA = schema + ? JSON.stringify(z.toJSONSchema(schema), null, 2) + : "(no input schema for this topic)"; + } + const rendered = sprintf(applyCliInvocation(content), variables); + return options.header === false ? stripHeader(rendered) : rendered; +} + +/** Every recipe opens with this marker on its first line. */ +const HEADER_PREFIX = "# Topic:"; + +/** + * Drop the leading header block from rendered recipe text: the `# Topic: …` + * line itself plus the single blank line that separates it from the body. + * Everything after that is returned untouched, so the body of a header-less + * rendering is byte-identical to the default rendering's body. + * + * Deliberately anchored to the first line only. A `# Topic:` string later in + * a recipe (inside a fenced example, say) is left alone, and a recipe that + * somehow lacks the header is returned unchanged rather than losing its + * first real line. + */ +function stripHeader(content: string): string { + const firstBreak = content.indexOf("\n"); + if (firstBreak === -1) { + return content.startsWith(HEADER_PREFIX) ? "" : content; + } + if (!content.startsWith(HEADER_PREFIX)) return content; + const body = content.slice(firstBreak + 1); + return body.startsWith("\n") ? body.slice(1) : body; +} + +/** + * Look up a help topic from the embedded recipe map and return the rendered + * text. Anonymous variants are preferred when `anonymous` is set and a + * variant exists; otherwise the canonical recipe is returned. Returns + * `undefined` when the topic is unknown. + */ +export function getRecipe( + topic: string, + options: RecipeOptions = {} +): string | undefined { + const content = options.anonymous + ? (anonymousMap.get(topic) ?? helpMap.get(topic)) + : helpMap.get(topic); + if (content === undefined) return undefined; + return renderRecipe(content, topic, options); +}