Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/export-knowledge-prompts.md
Original file line number Diff line number Diff line change
@@ -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 <topic>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ 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

The export SHALL provide a `PromptTopic` union of the available canonical topics, a `PROMPTS: Record<PromptTopic, (options?: PromptOptions) => 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`
- **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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

- [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<PromptTopic, (options?: PromptOptions) => 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<PromptTopic, (options?: PromptOptions) => 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

## 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 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

- [ ] 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 `<package-manager-dlx>` 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 `<package-manager-dlx>` 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
142 changes: 142 additions & 0 deletions openspec/specs/cli-knowledge-prompts/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# cli-knowledge-prompts Specification

## Purpose

The CLI's `help/*.txt` recipes are the authoritative guidance Taskless gives an
Comment thread
thecodedrift marked this conversation as resolved.
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

### 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<PromptTopic, (options?: PromptOptions) => 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

Comment thread
thecodedrift marked this conversation as 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 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 recipe carrying `%(PACKAGE_MANAGER_DLX)s` is rendered without options (today `ci`, an internal topic)
- **THEN** the placeholder renders as the default `<package-manager-dlx>` 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 `<topic>.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/<topic>.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/<topic>.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`
6 changes: 5 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
thecodedrift marked this conversation as resolved.
"generate:api": "openapi-typescript https://app.taskless.io/cli/api/__schema -o src/generated/api.d.ts",
Expand All @@ -23,6 +23,10 @@
"exports": {
".": {
"import": "./dist/index.js"
},
"./prompts": {
"types": "./dist/prompts/index.d.ts",
"import": "./dist/prompts.js"
}
},
"files": [
Expand Down
Loading
Loading