From 50b459c791025a17e9a76104b221c8bcd19e77fc Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 16:43:32 -0700 Subject: [PATCH 1/6] feat(cli): export the knowledge prompts as @taskless/cli/prompts Adds a `./prompts` subpath export so consumers can import the CLI's `help/*.txt` recipes instead of keeping a copy that drifts. Prompts are functions returning fully-rendered text: every `%(KEY)s` resolves inside the package, so no consumer handles a template dialect. `PromptOptions` carries `anonymous`, `packageManagerDlx`, and `header`. `header: false` exists because the header carries the CLI version, which would otherwise sit in an LLM consumer's prompt-cache key and be invalidated by every publish. `TOPICS` ships `static` alone, per design D6. The other 17 canonical topics are recorded in `INTERNAL_TOPICS`, and a completeness check fails when a recipe file is neither exported nor explicitly internal. Topic names are semver-tracked public API, so exporting one speculatively spends that promise for nothing. Two build details worth a look: Declarations come from `tsc --emitDeclarationOnly` against a scoped `tsconfig.prompts.json`, not `vite-plugin-dts`. A whole-`src` dts plugin would emit `dist/index.d.ts` as a side effect, and since the `"."` export has no `types` condition, TypeScript would fall back to that sibling file and hand consumers a typed CLI surface the package has never promised. The `"."` export block is untouched. `tsc` mirrors `rootDir`, so the declaration lands at `dist/prompts/index.d.ts` rather than the literal `dist/prompts.d.ts` task 2.2 names; the export map points at it directly. The Vite `shebang()` plugin is now scoped to the `index` entry. It prepended `#!/usr/bin/env node` to every entry chunk, which would have made the importable module an executable script. Also corrects three stale `route` examples in the spec delta that contradicted D6, and archives the change. Unit 2 of 2 for export-knowledge-prompts. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/export-knowledge-prompts.md | 9 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-knowledge-prompts/spec.md | 10 +- .../tasks.md | 16 +- openspec/specs/cli-knowledge-prompts/spec.md | 130 ++++++++ packages/cli/package.json | 6 +- packages/cli/src/prompts/index.ts | 97 ++++++ packages/cli/test/prompts.test.ts | 297 ++++++++++++++++++ packages/cli/tsconfig.prompts.json | 19 ++ packages/cli/vite.config.ts | 28 +- 12 files changed, 594 insertions(+), 18 deletions(-) create mode 100644 .changeset/export-knowledge-prompts.md rename openspec/changes/{export-knowledge-prompts => archive/2026-08-06-export-knowledge-prompts}/.openspec.yaml (100%) rename openspec/changes/{export-knowledge-prompts => archive/2026-08-06-export-knowledge-prompts}/design.md (100%) rename openspec/changes/{export-knowledge-prompts => archive/2026-08-06-export-knowledge-prompts}/proposal.md (100%) rename openspec/changes/{export-knowledge-prompts => archive/2026-08-06-export-knowledge-prompts}/specs/cli-knowledge-prompts/spec.md (95%) rename openspec/changes/{export-knowledge-prompts => archive/2026-08-06-export-knowledge-prompts}/tasks.md (82%) create mode 100644 openspec/specs/cli-knowledge-prompts/spec.md create mode 100644 packages/cli/src/prompts/index.ts create mode 100644 packages/cli/test/prompts.test.ts create mode 100644 packages/cli/tsconfig.prompts.json diff --git a/.changeset/export-knowledge-prompts.md b/.changeset/export-knowledge-prompts.md new file mode 100644 index 00000000..45f9d9f6 --- /dev/null +++ b/.changeset/export-knowledge-prompts.md @@ -0,0 +1,9 @@ +--- +"@taskless/cli": minor +--- + +Add a `@taskless/cli/prompts` subpath export exposing the CLI's knowledge prompts as importable, topic-keyed render functions. + +`getPrompt(topic, options?)` and the `PROMPTS` map return fully rendered recipe text, with every `%(KEY)s` placeholder already resolved from values the package holds, so a consumer never handles a template dialect. Topic names are typed as `PromptTopic` and start at `static`, the one recipe a service-side consumer can act on; everything else stays internal until a consumer needs it. `PromptOptions` covers the anonymous variant, a `packageManagerDlx` override, and `header: false` for callers placing the text in an LLM system prompt, where the CLI version in the header would otherwise churn the prompt-cache key on every publish. + +The export is sourced from the same embedded recipes and the same render path `taskless help ` serves, so the two surfaces cannot drift, and it carries no CLI runtime, so a Worker can import it without pulling in the command tree. diff --git a/openspec/changes/export-knowledge-prompts/.openspec.yaml b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/.openspec.yaml similarity index 100% rename from openspec/changes/export-knowledge-prompts/.openspec.yaml rename to openspec/changes/archive/2026-08-06-export-knowledge-prompts/.openspec.yaml diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md similarity index 100% rename from openspec/changes/export-knowledge-prompts/design.md rename to openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/proposal.md similarity index 100% rename from openspec/changes/export-knowledge-prompts/proposal.md rename to openspec/changes/archive/2026-08-06-export-knowledge-prompts/proposal.md diff --git a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md similarity index 95% rename from openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md rename to openspec/changes/archive/2026-08-06-export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md index 8ea23cb6..c925cf15 100644 --- a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md @@ -7,7 +7,7 @@ The package SHALL expose its knowledge prompts (the `help/*.txt` recipes) throug #### 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 +- **THEN** it receives the recipe text for a known topic (e.g. `static`) as a string ### Requirement: The prompt export is typed @@ -15,13 +15,13 @@ The export SHALL provide a `PromptTopic` union of the available canonical topics #### 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` +- **WHEN** a consumer calls `getPrompt("static")` +- **THEN** it type-checks and returns the `static` 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")` +- **WHEN** a consumer calls `PROMPTS.static()` with no arguments +- **THEN** it returns the same string as `getPrompt("static")` ### Requirement: The export and the help command share one source and one renderer diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md similarity index 82% rename from openspec/changes/export-knowledge-prompts/tasks.md rename to openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md index f8a9e67d..a15a1a8d 100644 --- a/openspec/changes/export-knowledge-prompts/tasks.md +++ b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md @@ -9,14 +9,14 @@ ## 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` +- [x] 2.1 Add the `./prompts` subpath to `package.json` `exports` (→ `./dist/prompts.js`, with `types`) and keep `files: ["dist"]` +- [x] 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 +- [x] 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 +- [x] 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 +- [x] 3.2 Test: rendered output from the built `dist/prompts.js` contains no un-inlined build define (e.g. a literal `__VERSION__`) +- [x] 3.3 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) +- [x] 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 +- [x] 3.5 `pnpm --filter @taskless/cli typecheck && lint && test` clean; `vite build` emits both entries diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..7af02140 --- /dev/null +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,130 @@ +# cli-knowledge-prompts Specification + +## Purpose + +TBD - created by archiving change export-knowledge-prompts. Update Purpose after archive. + +## 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. `static`) 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("static")` +- **THEN** it type-checks and returns the `static` recipe; `getPrompt("nope")` fails type-checking against `PromptTopic` + +#### Scenario: A topic is callable from the map + +- **WHEN** a consumer calls `PROMPTS.static()` with no arguments +- **THEN** it returns the same string as `getPrompt("static")` + +### 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/packages/cli/package.json b/packages/cli/package.json index 76e4df83..6b0d1a5e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,7 +8,7 @@ "directory": "packages/cli" }, "scripts": { - "build": "vite build", + "build": "vite build && tsc -p tsconfig.prompts.json", "build:dev": "TASKLESS_BUILD_TARGET=dev vite build", "build:self": "TASKLESS_BUILD_TARGET=self vite build", "generate:api": "openapi-typescript https://app.taskless.io/cli/api/__schema -o src/generated/api.d.ts", @@ -23,6 +23,10 @@ "exports": { ".": { "import": "./dist/index.js" + }, + "./prompts": { + "types": "./dist/prompts/index.d.ts", + "import": "./dist/prompts.js" } }, "files": [ diff --git a/packages/cli/src/prompts/index.ts b/packages/cli/src/prompts/index.ts new file mode 100644 index 00000000..4ae5d4f2 --- /dev/null +++ b/packages/cli/src/prompts/index.ts @@ -0,0 +1,97 @@ +// The `.js` extension is deliberate and is the only import in the package that +// carries one. This module is the published entry for `@taskless/cli/prompts`, +// so `tsc` copies this specifier verbatim into `dist/prompts/index.d.ts`. An +// extensionless specifier there fails to resolve for a consumer on +// `moduleResolution: node16`/`nodenext`, which is a trap we would be shipping +// rather than hitting ourselves. Both the type-checker and Vite map `.js` back +// to this `.ts` source, so nothing else changes. +import { getRecipe, type RecipeOptions } from "./recipes.js"; + +/** + * Public entry for `@taskless/cli/prompts`. + * + * Everything here renders through the same embedded recipe text and the same + * render path `taskless help ` serves, so the two surfaces cannot emit + * different guidance. Nothing in this graph reaches the CLI runtime: no citty + * command tree, no telemetry, no filesystem or network, so a Worker can import + * it without dragging the CLI in behind it. + */ + +/** + * Topics exported as public API. Hand-maintained rather than derived from the + * recipe files, because an exported name is a promise held for a major version + * and a new `help/*.txt` must not be able to publish one by existing. The + * completeness check in `test/prompts.test.ts` asserts this list plus + * {@link INTERNAL_TOPICS} accounts for every canonical recipe on disk. + * + * The list starts at what a consumer has actually asked for and grows on + * demand. `static` is the canonical on-disk rule shape, the one topic the + * generator's decision router can use server-side. + */ +export const TOPICS = ["static"] as const; + +/** + * Recipes deliberately withheld from the export, recorded so they stay visible + * decisions rather than oversights. Two groups: + * + * - Command recipes (`auth` … `update`) walk an agent through running a CLI + * subcommand on a developer's machine. There is no caller for them outside + * the CLI that hosts those commands. + * - Authoring recipes are unreachable server-side: `route` picks an authoring + * destination before the service is involved, `remote` states the boundary + * from the client's side, `detect` documents a CLI subprocess a Worker + * cannot spawn, `existing` targets a local toolchain, and `rule-meta` reads + * a `rule improve` sidecar file. + */ +export const INTERNAL_TOPICS = [ + "auth", + "check", + "ci", + "detect", + "existing", + "info", + "init", + "onboard", + "remote", + "route", + "rule", + "rule-create", + "rule-delete", + "rule-improve", + "rule-meta", + "rule-verify", + "update", +] as const; + +/** A topic name the package exports. Unknown names fail to type-check. */ +export type PromptTopic = (typeof TOPICS)[number]; + +/** Options accepted by every prompt render function. */ +export type PromptOptions = RecipeOptions; + +/** + * Render a prompt to finished text. Every `%(KEY)s` placeholder is resolved + * from values the package already holds, so the caller never handles a + * template dialect. + * + * @throws when the topic has no canonical recipe in the build, which means + * {@link TOPICS} and the recipe files have diverged. + */ +export function getPrompt(topic: PromptTopic, options?: PromptOptions): string { + const rendered = getRecipe(topic, options); + if (rendered === undefined) { + throw new Error( + `No recipe is embedded for prompt topic "${topic}". This is a packaging fault: TOPICS lists a topic with no help/${topic}.txt behind it.` + ); + } + return rendered; +} + +/** Every exported topic as a render function, keyed by topic name. */ +export const PROMPTS: Record string> = + Object.fromEntries( + TOPICS.map((topic) => [ + topic, + (options?: PromptOptions) => getPrompt(topic, options), + ]) + ) as Record string>; diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts new file mode 100644 index 00000000..008aab0a --- /dev/null +++ b/packages/cli/test/prompts.test.ts @@ -0,0 +1,297 @@ +import { execFile } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +import { + PROMPTS, + TOPICS, + INTERNAL_TOPICS, + getPrompt, + type PromptOptions, +} from "../src/prompts/index"; +import { canonicalRecipeTopics, getRecipe } from "../src/prompts/recipes"; + +const execFileAsync = promisify(execFile); + +const helpDirectory = resolve(import.meta.dirname, "../src/help"); +const distributionDirectory = resolve(import.meta.dirname, "../dist"); +const binPath = resolve(distributionDirectory, "index.js"); +const distributionPromptsPath = resolve(distributionDirectory, "prompts.js"); + +/** A `%(KEY)s` placeholder that survived rendering. */ +const UNRESOLVED_PLACEHOLDER = /%\([A-Z_]+\)s/; + +/** + * Import the built prompts entry the way a consumer would. The specifier is a + * runtime value so it stays a real ESM import of the artifact rather than + * something the bundler or the type-checker resolves back to source. + */ +async function importBuiltPrompts(): Promise<{ + getPrompt: (topic: "static", options?: PromptOptions) => string; + TOPICS: readonly string[]; +}> { + const url = pathToFileURL(distributionPromptsPath).href; + return (await import(/* @vite-ignore */ url)) as { + getPrompt: (topic: "static", options?: PromptOptions) => string; + TOPICS: readonly string[]; + }; +} + +/** Canonical `.txt` names on disk, excluding `.anonymous` variants. */ +async function canonicalTopicsOnDisk(): Promise { + const entries = await readdir(helpDirectory); + return entries + .filter((name) => name.endsWith(".txt")) + .map((name) => name.slice(0, -".txt".length)) + .filter((stem) => !stem.endsWith(".anonymous")) + .toSorted(); +} + +describe("prompt rendering", () => { + it("resolves every placeholder in every canonical recipe", async () => { + for (const topic of await canonicalTopicsOnDisk()) { + const rendered = getRecipe(topic); + expect(rendered, `no recipe embedded for ${topic}`).toBeDefined(); + expect(rendered, `unresolved placeholder in ${topic}`).not.toMatch( + UNRESOLVED_PLACEHOLDER + ); + } + }); + + it("renders the CLI version into the header", () => { + expect(getPrompt("static")).toContain(`CLI v${__VERSION__}`); + }); + + it.each([ + ["rule-create", "prompt"], + ["rule-improve", "ruleId"], + ])("renders the JSON Schema for %s", (topic, property) => { + const rendered = getRecipe(topic) ?? ""; + expect(rendered, `${topic} lost its schema`).not.toContain( + "(no input schema for this topic)" + ); + // The rendered schema is JSON, so its keys survive verbatim. + expect(rendered, `${topic} schema not rendered`).toContain('"$schema"'); + expect(rendered).toContain(`"${property}"`); + }); + + it("renders the package-manager marker by default and honors an override", () => { + const withDefault = getRecipe("ci") ?? ""; + expect(withDefault).toContain(""); + + const withOverride = + getRecipe("ci", { packageManagerDlx: "pnpm dlx" }) ?? ""; + expect(withOverride).toContain("pnpm dlx"); + expect(withOverride).not.toContain(""); + }); + + it("returns undefined for an unknown topic", () => { + expect(getRecipe("no-such-topic")).toBeUndefined(); + }); +}); + +describe("header suppression", () => { + it("drops the header line and the blank line after it, leaving the body intact", () => { + const withHeader = getPrompt("static"); + const withoutHeader = getPrompt("static", { header: false }); + + expect(withHeader.startsWith("# Topic: static")).toBe(true); + expect(withoutHeader.startsWith("# Topic:")).toBe(false); + // The body is the same string, minus the header line and its blank line. + expect(withoutHeader).toBe(withHeader.split("\n").slice(2).join("\n")); + }); + + it("leaves no CLI version string behind", () => { + for (const topic of TOPICS) { + const withoutHeader = getPrompt(topic, { header: false }); + expect(withoutHeader, `${topic} kept the version`).not.toContain( + __VERSION__ + ); + expect(withoutHeader, `${topic} kept a version header`).not.toMatch( + /CLI v\d/ + ); + } + }); + + it("keeps the header by default", () => { + expect(getPrompt("static")).toBe(getPrompt("static", { header: true })); + expect(getPrompt("static")).toBe(getPrompt("static", {})); + }); +}); + +describe("anonymous variants", () => { + it("returns the variant text for a topic that has one", () => { + const canonical = getRecipe("rule-create"); + const anonymous = getRecipe("rule-create", { anonymous: true }); + expect(anonymous).toBeDefined(); + expect(anonymous).not.toBe(canonical); + expect(anonymous).toContain("(anonymous)"); + }); + + it("falls back to the canonical recipe for a topic without one", () => { + expect(getRecipe("static", { anonymous: true })).toBe(getRecipe("static")); + }); +}); + +describe("typed accessor", () => { + it("exposes a render function per exported topic", () => { + expect(Object.keys(PROMPTS).toSorted()).toEqual(TOPICS.toSorted()); + for (const topic of TOPICS) { + expect(PROMPTS[topic]()).toBe(getPrompt(topic)); + } + }); + + it("passes options through the map", () => { + expect(PROMPTS.static({ header: false })).toBe( + getPrompt("static", { header: false }) + ); + }); + + it("rejects an unknown topic at compile time", () => { + // @ts-expect-error "nope" is not a member of PromptTopic + expect(() => getPrompt("nope")).toThrow(); + }); +}); + +describe("help command parity", () => { + it.each([...TOPICS])( + "matches `taskless help %s` byte for byte", + async (topic) => { + const { stdout } = await execFileAsync("node", [binPath, "help", topic]); + // The command trims trailing whitespace before printing; console.log then + // adds the single newline that stdout carries. + expect(stdout.trimEnd()).toBe(getPrompt(topic).trimEnd()); + } + ); +}); + +describe("topic membership", () => { + it("classifies every canonical recipe as exported or internal", async () => { + const onDisk = await canonicalTopicsOnDisk(); + const classified = [...TOPICS, ...INTERNAL_TOPICS].toSorted(); + + // Fails in both directions: an unclassified new recipe, and an exported or + // internal topic whose recipe file is gone. + expect(classified).toEqual(onDisk); + // The embed must agree with the disk too, so a stale glob cannot hide a + // divergence the check is meant to catch. + expect(canonicalRecipeTopics().toSorted()).toEqual(onDisk); + }); + + it("keeps the two lists disjoint", () => { + const overlap = TOPICS.filter((topic) => + (INTERNAL_TOPICS as readonly string[]).includes(topic) + ); + expect(overlap).toEqual([]); + }); +}); + +describe("built prompts entry", () => { + it("emits the entry and its declarations", async () => { + await expect( + readFile(distributionPromptsPath, "utf8") + ).resolves.toBeTruthy(); + await expect( + readFile(resolve(distributionDirectory, "prompts/index.d.ts"), "utf8") + ).resolves.toContain("getPrompt"); + }); + + it("is a library module, not an executable script", async () => { + const source = await readFile(distributionPromptsPath, "utf8"); + // The shebang plugin serves the `bin` entry. A `#!` line here would be a + // syntax error for anything that imports the module. + expect(source.startsWith("#!")).toBe(false); + const bin = await readFile(binPath, "utf8"); + expect(bin.startsWith("#!/usr/bin/env node")).toBe(true); + }); + + it("inlines every build define", async () => { + const { getPrompt: getBuiltPrompt } = await importBuiltPrompts(); + const rendered = getBuiltPrompt("static"); + expect(rendered).toContain(`CLI v${__VERSION__}`); + expect(rendered).not.toMatch(/__[A-Z_]+__/); + expect(rendered).not.toMatch(UNRESOLVED_PLACEHOLDER); + }); + + it("renders identically from the built artifact and from source", async () => { + const { getPrompt: getBuiltPrompt } = await importBuiltPrompts(); + expect(getBuiltPrompt("static")).toBe(getPrompt("static")); + expect(getBuiltPrompt("static", { header: false })).toBe( + getPrompt("static", { header: false }) + ); + }); +}); + +/** Module specifiers a built chunk imports, static and dynamic. */ +function importSpecifiers(source: string): string[] { + const specifiers = new Set(); + for (const match of source.matchAll(/\bfrom\s*["']([^"']+)["']/g)) { + specifiers.add(match[1]!); + } + for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']/g)) { + specifiers.add(match[1]!); + } + for (const match of source.matchAll(/\bimport\s*["']([^"']+)["']/g)) { + specifiers.add(match[1]!); + } + return [...specifiers]; +} + +describe("prompts entry carries no CLI runtime", () => { + // Everything the render path is allowed to reach: embedded text, the two leaf + // Zod schemas, the invocation rewrite, and the templating library. + const ALLOWED_SOURCE_IMPORTS = new Set([ + "sprintf-js", + "zod", + "../util/invocation", + "../schemas/rules-create", + "../schemas/rules-improve", + "./recipes.js", + ]); + + it("imports nothing outside the allowlist at source level", async () => { + for (const file of ["index.ts", "recipes.ts"]) { + const source = await readFile( + resolve(import.meta.dirname, "../src/prompts", file), + "utf8" + ); + for (const specifier of importSpecifiers(source)) { + expect( + ALLOWED_SOURCE_IMPORTS.has(specifier), + `src/prompts/${file} imports ${specifier}` + ).toBe(true); + } + } + }); + + it("never reaches the CLI entry or a host capability once built", async () => { + const seen = new Set(); + const queue = [distributionPromptsPath]; + + while (queue.length > 0) { + const file = queue.pop()!; + if (seen.has(file)) continue; + seen.add(file); + const source = await readFile(file, "utf8"); + for (const specifier of importSpecifiers(source)) { + if (specifier.startsWith(".")) { + queue.push(resolve(dirname(file), specifier)); + continue; + } + // A bare specifier here would be an unbundled runtime dependency; the + // lib build bundles everything except node builtins, so any survivor is + // a builtin the prompts graph has no business touching. + expect(specifier, `dist/prompts.js graph imports ${specifier}`).toBe( + "" + ); + } + } + + expect( + [...seen].map((file) => file.replace(`${distributionDirectory}/`, "")) + ).not.toContain("index.js"); + }); +}); diff --git a/packages/cli/tsconfig.prompts.json b/packages/cli/tsconfig.prompts.json new file mode 100644 index 00000000..27792be4 --- /dev/null +++ b/packages/cli/tsconfig.prompts.json @@ -0,0 +1,19 @@ +// Declaration emit for the `@taskless/cli/prompts` subpath export only. +// +// Vite's lib build emits JavaScript and no types, so `tsc` supplies the `.d.ts` +// for the one entry that is a public importable API. The include list is +// deliberately just the prompts entry (plus the ambient build defines it needs) +// rather than all of `src`: emitting a `dist/index.d.ts` next to `dist/index.js` +// would give the CLI's main entry a typed public surface it does not have today +// and has never promised, purely as a side effect of typing the new one. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "sourceMap": false, + "rootDir": "src", + "declarationDir": "dist" + }, + "include": ["src/globals.d.ts", "src/prompts/index.ts"] +} diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 5431789e..9a17d982 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -131,19 +131,36 @@ function assertSkillVersions(): Plugin { }; } +// The build emits two entries. `index` is the executable CLI; `prompts` is a +// library module consumers import as `@taskless/cli/prompts`. Only the former +// is a program, so only the former gets a shebang and the executable bit — a +// `#!` line on the library entry would be a syntax error to anything importing +// it as a module. +const BIN_ENTRY = "index"; +const PROMPTS_ENTRY = "prompts"; + function shebang(): Plugin { + const isBinEntry = (chunk: { + type: string; + isEntry?: boolean; + name?: string; + }) => + chunk.type === "chunk" && + chunk.isEntry === true && + chunk.name === BIN_ENTRY; + return { name: "shebang", generateBundle(_options, bundle) { for (const chunk of Object.values(bundle)) { - if (chunk.type === "chunk" && chunk.isEntry) { + if (chunk.type === "chunk" && isBinEntry(chunk)) { chunk.code = "#!/usr/bin/env node\n" + chunk.code; } } }, writeBundle(options, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { - if (chunk.type === "chunk" && chunk.isEntry) { + if (isBinEntry(chunk)) { const outPath = resolve(options.dir ?? resolveOutDir(), fileName); chmodSync(outPath, 0o755); } @@ -162,9 +179,12 @@ export default defineConfig({ build: { outDir: resolveOutDir(), lib: { - entry: resolve(import.meta.dirname, "src/index.ts"), + entry: { + [BIN_ENTRY]: resolve(import.meta.dirname, "src/index.ts"), + [PROMPTS_ENTRY]: resolve(import.meta.dirname, "src/prompts/index.ts"), + }, formats: ["es"], - fileName: "index", + fileName: (_format, entryName) => `${entryName}.js`, }, rollupOptions: { external: [/^node:/, ...builtinModules], From 9ecc3ed71a6ea11c642ae60502c34697eda15e81 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 17:39:23 -0700 Subject: [PATCH 2/6] fix(cli): copy the whole dist when isolating the missing-binary test The prompts entry makes the build emit two library entries, so rollup hoists what `index` and `prompts` share into a sibling chunk that `dist/index.js` imports by relative path. The missing-binary test copied only the bin into its isolated directory, leaving that import dangling: the CLI died on ERR_MODULE_NOT_FOUND before it ever looked for ast-grep, and the assertion reported an empty stderr rather than the real cause. Copy the whole `dist/` instead. The isolation the test needs is the empty PATH and HOME, not a single-file bundle. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/test/sg-committed-config.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/cli/test/sg-committed-config.test.ts b/packages/cli/test/sg-committed-config.test.ts index 7e2c6daf..6a5d00ae 100644 --- a/packages/cli/test/sg-committed-config.test.ts +++ b/packages/cli/test/sg-committed-config.test.ts @@ -1,12 +1,5 @@ import { execFile } from "node:child_process"; -import { - copyFile, - mkdir, - mkdtemp, - rm, - stat, - writeFile, -} from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -16,7 +9,8 @@ import { ensureTasklessDirectory } from "../src/filesystem/directory"; import { verifyRule } from "../src/rules/verify"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const distributionDirectory = resolve(import.meta.dirname, "../dist"); +const binPath = join(distributionDirectory, "index.js"); /** `.taskless/sgconfig.yml` — written only when a rule set has no committed config. */ const EPHEMERAL_CONFIG = ["sgconfig.yml"]; @@ -238,13 +232,18 @@ describe("ast-grep binary is missing", () => { // on a machine where either points at a real ast-grep the resolver finds // one, the scan succeeds, and the test fails asserting an error that never // needed to be printed. + // + // The whole `dist/` is copied, not just `index.js`. The build emits two + // library entries (`index` and `prompts`), so rollup hoists what they share + // into a sibling chunk that `index.js` imports by relative path — copying + // the bin alone leaves that import dangling and the CLI dies on + // ERR_MODULE_NOT_FOUND before it ever looks for ast-grep. const isolated = join(temporaryDirectory, "cli"); const project = join(temporaryDirectory, "project"); const emptyPath = join(temporaryDirectory, "empty"); - await mkdir(isolated, { recursive: true }); await mkdir(emptyPath, { recursive: true }); await mkdir(project, { recursive: true }); - await copyFile(binPath, join(isolated, "index.js")); + await cp(distributionDirectory, isolated, { recursive: true }); await ensureTasklessDirectory(project); await writeFile( From 0fc58c47c60ee29d940de49cb86d924dd2575c60 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 17:39:36 -0700 Subject: [PATCH 3/6] docs(openspec): fill in the cli-knowledge-prompts purpose and scope its scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promoted spec still carried the archive placeholder for Purpose, which reads to a later maintainer as an unfinished spec. State what the capability is for and why one source and one renderer matter. Two scenarios also described `rule-create`/`rule-improve`/`ci` as prompts a consumer calls, but all three are INTERNAL_TOPICS and are not members of `PromptTopic` — `getPrompt("ci")` does not type-check. Reword them around the recipe carrying the placeholder, which is what the render path actually guarantees, and name the topics as internal. Co-Authored-By: Claude Opus 5 (1M context) --- openspec/specs/cli-knowledge-prompts/spec.md | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index 7af02140..4e967642 100644 --- a/openspec/specs/cli-knowledge-prompts/spec.md +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -2,7 +2,19 @@ ## Purpose -TBD - created by archiving change export-knowledge-prompts. Update Purpose after archive. +The CLI's `help/*.txt` recipes are the authoritative guidance Taskless gives an +agent about authoring and operating rules. Until now the only way to read them +was to run `taskless help`, which puts them out of reach of anything that cannot +spawn the CLI — notably the service-side generator, which needs the same text to +brief a model. + +This capability publishes those recipes as a typed subpath export, +`@taskless/cli/prompts`, rendered through the same embed and the same render +path the `help` command uses. One source and one renderer means the two surfaces +cannot drift into giving different guidance. The export carries no CLI runtime, +so a Worker can import it without dragging in the command tree, and topic +membership is an explicit hand-maintained list so a new recipe file cannot +silently become public API. ## Requirements @@ -49,13 +61,13 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub #### 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 +- **WHEN** a recipe carrying `%(INPUT_SCHEMA)s` is rendered (today `rule-create` and `rule-improve`, both internal topics) +- **THEN** the placeholder 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 +- **WHEN** a recipe carrying `%(PACKAGE_MANAGER_DLX)s` is rendered without options (today `ci`, an internal topic) +- **THEN** the placeholder renders as the default `` marker; supplying `packageManagerDlx` substitutes that value instead ### Requirement: The version header is suppressible From 8823b4ef2868b058bb01000a1f95839093053c47 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 17:40:55 -0700 Subject: [PATCH 4/6] docs(openspec): re-check the tasks this unit completes in the archive Unit 1 unchecked tasks 1.3 and 1.4 because it does not implement them. This unit does, so the archived record checks them again. Co-Authored-By: Claude Opus 5 (1M context) --- .../archive/2026-08-06-export-knowledge-prompts/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md index a15a1a8d..80180a1a 100644 --- a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md +++ b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md @@ -2,8 +2,8 @@ - [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.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 +- [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 - [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 From 5dabcfbdfc6d23685d9995877320ea74a39ecd59 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 17:48:11 -0700 Subject: [PATCH 5/6] ref(cli): type the shebang predicate against rollup, guard the untyped CLI entry Three review findings on the build wiring: `isBinEntry` took a hand-rolled structural shape whose fields were all optional, so an `OutputAsset` satisfied it by having none of them. Take Rollup's own bundle union and return a type predicate instead, which also lets `generateBundle` drop its redundant `type === "chunk"` guard. `tsconfig.prompts.json`'s comment implied its include list bounds what gets emitted. It bounds which entry is rooted; emit follows the import graph, so `prompts/recipes`, `util/invocation`, and the two leaf schemas get declarations too. Say so, and record why `declarationDir` stays `dist` while `vite.config.ts` derives its output dir from TASKLESS_BUILD_TARGET: the `./prompts` export resolves to `./dist/prompts.js` unconditionally. The absence of `dist/index.d.ts` was the invariant that scoping exists to protect and the only one with no test behind it. Assert it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/test/prompts.test.ts | 10 ++++++++++ packages/cli/tsconfig.prompts.json | 16 ++++++++++++++++ packages/cli/vite.config.ts | 19 +++++++++---------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 008aab0a..4ed60093 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -199,6 +199,16 @@ describe("built prompts entry", () => { ).resolves.toContain("getPrompt"); }); + it("leaves the CLI entry untyped", async () => { + // The `.` export has no `types` condition. A `dist/index.d.ts` emitted + // beside `dist/index.js` would hand consumers a typed CLI surface the + // package never promised, as a side effect of typing the prompts entry — + // which is the whole reason `tsconfig.prompts.json` scopes its include list. + await expect( + readFile(resolve(distributionDirectory, "index.d.ts"), "utf8") + ).rejects.toThrow(/ENOENT/); + }); + it("is a library module, not an executable script", async () => { const source = await readFile(distributionPromptsPath, "utf8"); // The shebang plugin serves the `bin` entry. A `#!` line here would be a diff --git a/packages/cli/tsconfig.prompts.json b/packages/cli/tsconfig.prompts.json index 27792be4..8508b067 100644 --- a/packages/cli/tsconfig.prompts.json +++ b/packages/cli/tsconfig.prompts.json @@ -6,6 +6,22 @@ // rather than all of `src`: emitting a `dist/index.d.ts` next to `dist/index.js` // would give the CLI's main entry a typed public surface it does not have today // and has never promised, purely as a side effect of typing the new one. +// +// The include list bounds which entry is rooted here, not how many files are +// emitted. Declaration emit follows the import graph, so the modules the entry +// reaches get one too: `prompts/recipes`, `util/invocation`, and the two leaf +// Zod schemas. They ship in the tarball under `files: ["dist"]` but no `exports` +// condition points at them, so they stay reachable only as the types this +// entry's own signatures reference. `dist/index.d.ts` — the thing this scoping +// exists to prevent — is absent, which `test/prompts.test.ts` asserts. +// +// `declarationDir` is `dist` rather than following `TASKLESS_BUILD_TARGET` the +// way `vite.config.ts`'s `resolveOutDir()` does, because the `./prompts` export +// resolves to `./dist/prompts.js` unconditionally. The `dev` and `self` targets +// exist to run the CLI binary from a local path, and nothing resolves a prompts +// entry out of `dist-dev`/`dist-self`. Wiring this step into those builds means +// overriding `--declarationDir` to match, or the declarations land beside the +// wrong JavaScript. { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 9a17d982..1f9a8cae 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -2,7 +2,7 @@ import { builtinModules } from "node:module"; import { chmodSync, readFileSync, readdirSync } from "node:fs"; import { resolve, join } from "node:path"; import { parse } from "yaml"; -import type { Plugin } from "vite"; +import type { Plugin, Rollup } from "vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -140,20 +140,19 @@ const BIN_ENTRY = "index"; const PROMPTS_ENTRY = "prompts"; function shebang(): Plugin { - const isBinEntry = (chunk: { - type: string; - isEntry?: boolean; - name?: string; - }) => - chunk.type === "chunk" && - chunk.isEntry === true && - chunk.name === BIN_ENTRY; + // Typed against Rollup's own bundle union rather than a structural shape, so + // an asset cannot satisfy the parameter by having none of these fields. The + // predicate return lets both hooks narrow to the chunk they act on. + const isBinEntry = ( + chunk: Rollup.OutputAsset | Rollup.OutputChunk + ): chunk is Rollup.OutputChunk => + chunk.type === "chunk" && chunk.isEntry && chunk.name === BIN_ENTRY; return { name: "shebang", generateBundle(_options, bundle) { for (const chunk of Object.values(bundle)) { - if (chunk.type === "chunk" && isBinEntry(chunk)) { + if (isBinEntry(chunk)) { chunk.code = "#!/usr/bin/env node\n" + chunk.code; } } From a5c2334a6843c1d6761b9f11af8621112cb3498a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 18:02:31 -0700 Subject: [PATCH 6/6] docs(openspec): correct the declaration output path in the archived change Task 2.2 said the build emits `dist/prompts.d.ts`. Declarations come from `tsc --emitDeclarationOnly` against `tsconfig.prompts.json`, and `tsc` mirrors `rootDir` structure, so `src/prompts/index.ts` emits to `dist/prompts/index.d.ts`, which is the path the `exports` map already points at. Task 2.3 and the matching design risk carried the same imprecision. Co-Authored-By: Claude Opus 5 (1M context) --- .../archive/2026-08-06-export-knowledge-prompts/design.md | 2 +- .../archive/2026-08-06-export-knowledge-prompts/tasks.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md index 59098fa3..44db0e22 100644 --- a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md +++ b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/design.md @@ -97,7 +97,7 @@ Consumption is via a **normal published release** of `@taskless/cli`. A workspac ## 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 must emit the second entry** → configure Vite for a `prompts` entry and emit its declarations from a scoped `tsc` pass; a CI/test asserts `dist/prompts.js` + `dist/prompts/index.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. diff --git a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md index 80180a1a..b18717c6 100644 --- a/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md +++ b/openspec/changes/archive/2026-08-06-export-knowledge-prompts/tasks.md @@ -10,8 +10,8 @@ ## 2. Package export + build - [x] 2.1 Add the `./prompts` subpath to `package.json` `exports` (→ `./dist/prompts.js`, with `types`) and keep `files: ["dist"]` -- [x] 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 -- [x] 2.3 Add a build/CI assertion that `dist/prompts.js` and its types exist after `vite build` +- [x] 2.2 Configure the build to emit `dist/prompts.js` as a second Vite entry alongside `dist/index.js`, **with the same `define` block** (`__VERSION__`, `__TASKLESS_CLI__`) as the main entry, plus `dist/prompts/index.d.ts` from a scoped `tsc --emitDeclarationOnly` pass +- [x] 2.3 Add a build/CI assertion that `dist/prompts.js` and its types exist after `pnpm build` ## 3. Verify