From 004d7c4c7f2be5b6c3f7d4aa76749e20c950ee5a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 10:44:47 -0700 Subject: [PATCH 1/3] fix(prompts): render the agent-fill marker for a host that passes no invocation A nightly's @taskless/cli/prompts fell back to its own pinned invocation, so every %(TASKLESS_CLI)s in a recipe body rendered as npx @taskless/cli-nightly@, and a header: false render still carried the CLI version. The export now defaults to ; the agent command keeps naming the build it was run from. --- .changeset/prompts-host-invocation-marker.md | 5 ++ packages/cli/src/prompts/index.ts | 21 +++++- packages/cli/src/prompts/recipes.ts | 7 +- .../test/nightly/prompts-invocation.test.ts | 67 +++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .changeset/prompts-host-invocation-marker.md create mode 100644 packages/cli/test/nightly/prompts-invocation.test.ts diff --git a/.changeset/prompts-host-invocation-marker.md b/.changeset/prompts-host-invocation-marker.md new file mode 100644 index 00000000..f337c60b --- /dev/null +++ b/.changeset/prompts-host-invocation-marker.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`@taskless/cli/prompts` now renders the `` marker when the caller passes no `invocation`, on every build. A nightly used to substitute its own `npx @taskless/cli-nightly@` into the recipe body, so `getPrompt(topic, { header: false })` still carried a CLI version and the rendered text changed on every nightly. Pass `invocation` to name a launcher of your own. `taskless agent ` is unchanged and still names the build it was run from. diff --git a/packages/cli/src/prompts/index.ts b/packages/cli/src/prompts/index.ts index 9528b5f1..a0858730 100644 --- a/packages/cli/src/prompts/index.ts +++ b/packages/cli/src/prompts/index.ts @@ -9,6 +9,7 @@ import { getRawRecipe, getRecipe, getRenderedRecipe, + TASKLESS_CLI_MARKER, type RecipeOptions, type RecipeText, } from "./recipes.js"; @@ -131,7 +132,7 @@ export type { HostTool } from "./recipes.js"; * {@link TOPICS} and the recipe files have diverged. */ export function getPrompt(topic: PromptTopic, options?: PromptOptions): string { - return required(getRecipe(topic, options), topic); + return required(getRecipe(topic, forHost(options)), topic); } /** @@ -154,7 +155,7 @@ export function getInstructions( topic: PromptTopic, options?: PromptOptions ): Instructions { - return required(getRenderedRecipe(topic, options), topic); + return required(getRenderedRecipe(topic, forHost(options)), topic); } /** @@ -175,6 +176,22 @@ export function getRawInstructions( return required(getRawRecipe(topic, options), topic); } +/** + * Default the invocation to the agent-fill marker for a host that passed none. + * + * The shared render path falls back to THIS BUILD's invocation, which is right + * for `taskless agent ` and wrong here. A host that imported this + * package never launched it, so a nightly's + * `npx @taskless/cli-nightly@` names a command its reader does not + * have, and puts the version back into a `header: false` render whose whole + * point is a body that stays the same across CLI versions (taskless/cli#469). + * A release build already rendered the marker, which is why the defect only + * showed on a nightly. + */ +function forHost(options: PromptOptions = {}): PromptOptions { + return { ...options, invocation: options.invocation ?? TASKLESS_CLI_MARKER }; +} + /** * Turn a missing topic into the same packaging-fault error {@link getPrompt} * raises. `getRecipe` keeps its `undefined` contract because the `agent` diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index d4873142..9e14c012 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -94,7 +94,7 @@ const PACKAGE_MANAGER_DLX_MARKER = ""; * asked to supply the answer — so asking is strictly better than guessing a * launcher the reader may not have. */ -const TASKLESS_CLI_MARKER = ""; +export const TASKLESS_CLI_MARKER = ""; /** * One command-line tool a recipe can condition on, as the host measured it. @@ -182,7 +182,10 @@ export interface RecipeOptions { * `@taskless/cli/prompts` passes nothing and gets the marker. * * Omitting it falls back to this build's own invocation when the build is - * not prod, and to the agent-fill marker otherwise. + * not prod, and to the agent-fill marker otherwise. That fallback is for the + * CLI's own commands, which ARE this build. `@taskless/cli/prompts` never + * reaches it: the export supplies the marker itself, because a host that + * imported this package did not launch it (taskless/cli#469). * * @default "" */ diff --git a/packages/cli/test/nightly/prompts-invocation.test.ts b/packages/cli/test/nightly/prompts-invocation.test.ts new file mode 100644 index 00000000..91e3f595 --- /dev/null +++ b/packages/cli/test/nightly/prompts-invocation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + getInstructions, + getPrompt, + getRawInstructions, + TOPICS, +} from "../../src/prompts/index"; +import { getRecipe } from "../../src/prompts/recipes"; +import { + buildInvocation, + isProductionInvocation, +} from "../../src/util/invocation"; +import { CLI_VERSION } from "../../src/version"; + +/** + * taskless/cli#469, under the `nightly` vitest project (see `vite.config.ts`), + * where `__TASKLESS_CLI__` is a pinned `@taskless/cli-nightly@`. + * + * `@taskless/cli/prompts` fell back to the BUILD's invocation when the caller + * passed none, so a nightly rendered `npx @taskless/cli-nightly@` + * into every recipe body. A release build falls back to the agent-fill marker + * instead, which is why 0.11.2 rendered no version and the nightly rendered + * eleven. The importing host never launched this package, so how this build + * would be launched says nothing about its reader; and with `header: false` + * the version in the body defeated the cache stability the option exists for. + */ +const VERSIONED_INVOCATION = /@taskless\/cli[\w-]*@[\w.-]+/; + +describe("the prompts export under a nightly build", () => { + it("runs under a nightly define, not the released one", () => { + expect(isProductionInvocation()).toBe(false); + expect(buildInvocation()).toContain("@taskless/cli-nightly@"); + }); + + for (const topic of TOPICS) { + it(`renders ${topic} with no CLI version when the header is suppressed`, () => { + const options = { header: false, mechanics: false }; + for (const text of [ + getPrompt(topic, options), + getInstructions(topic, options).text, + getRawInstructions(topic, options).text, + ]) { + expect(text).not.toMatch(VERSIONED_INVOCATION); + expect(text).not.toContain(CLI_VERSION); + } + }); + + it(`renders ${topic} with the agent-fill marker when no invocation is passed`, () => { + const text = getPrompt(topic); + expect(text).not.toMatch(VERSIONED_INVOCATION); + expect(text).toContain(""); + }); + + it(`renders ${topic} with a caller-supplied invocation verbatim`, () => { + const text = getPrompt(topic, { invocation: "pnpm dlx @taskless/cli" }); + expect(text).toContain("pnpm dlx @taskless/cli agent"); + expect(text).not.toContain(""); + }); + } + + it("still names the nightly itself on the CLI's own render path", () => { + // `taskless agent ` IS this build, so when the launcher cannot be + // detected its own pinned invocation remains the right answer there. + expect(getRecipe("route")).toContain(`${buildInvocation()} agent`); + }); +}); From 76d58aa3094197cefa5ea3dbb11bf3dbf6aa40a1 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 10:53:58 -0700 Subject: [PATCH 2/3] fix(prompts): reduce recipe citations to topic names when mechanics are off route's final step, failure fallback and See Also named a recipe as a command to fetch, so a mechanics: false render still carried CLI invocations. Step 8 is now a whole-step substitution that names the destination by topic, and citations go through %(RECIPE_FETCH)s, which renders nothing with mechanics off. The default render is byte-identical. --- .changeset/prompts-host-invocation-marker.md | 2 + packages/cli/src/agent/route.md | 22 ++++----- packages/cli/src/prompts/recipes.ts | 48 ++++++++++++++++++-- packages/cli/test/prompts.test.ts | 27 +++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/.changeset/prompts-host-invocation-marker.md b/.changeset/prompts-host-invocation-marker.md index f337c60b..d53ef89a 100644 --- a/.changeset/prompts-host-invocation-marker.md +++ b/.changeset/prompts-host-invocation-marker.md @@ -3,3 +3,5 @@ --- `@taskless/cli/prompts` now renders the `` marker when the caller passes no `invocation`, on every build. A nightly used to substitute its own `npx @taskless/cli-nightly@` into the recipe body, so `getPrompt(topic, { header: false })` still carried a CLI version and the rendered text changed on every nightly. Pass `invocation` to name a launcher of your own. `taskless agent ` is unchanged and still names the build it was run from. + +`mechanics: false` now also covers the passages that name a recipe as a command to fetch. In `route`, the final step names the destination by its topic name (`create-vale-rule`) rather than by its fetch command, and the failure fallback and `See Also` cite topic names, so the render carries no CLI invocation at all. The default rendering is unchanged. diff --git a/packages/cli/src/agent/route.md b/packages/cli/src/agent/route.md index cf9ec77d..548bb403 100644 --- a/packages/cli/src/agent/route.md +++ b/packages/cli/src/agent/route.md @@ -241,13 +241,7 @@ answered together. Not logged in, no GitHub owner, or not locally expressible are not choices. Do not pose them as one. -8. **Name the command.** Finish by telling the user, or running, the - exact fetch for the destination you chose: - ``` - %(TASKLESS_CLI)s agent create-vale-rule - ``` - A destination that is not a runnable command is a category, and a - category is not an answer. +8. %(NAME_DESTINATION)s ## When two destinations both fit @@ -270,7 +264,7 @@ fails against the user's cases: - State that generating via the service uses a generation and requires login. - Call the service only after the user confirms. On yes, fetch - `%(TASKLESS_CLI)s agent create-remote-rule`. + `%(RECIPE_FETCH)screate-remote-rule`. Never fall through silently from a failed local attempt to a service call. A developer who watches a local attempt fail reads it as a @@ -304,9 +298,9 @@ will work. ## See Also -- `%(TASKLESS_CLI)s agent create-legacy-rule`: author in a linter the repo already uses -- `%(TASKLESS_CLI)s agent create-sg-rule`: author a local ast-grep rule (no login) -- `%(TASKLESS_CLI)s agent create-vale-rule`: author a local Vale rule (no login) -- `%(TASKLESS_CLI)s agent create-runtime-rule`: the runtime tier, logged out -- `%(TASKLESS_CLI)s agent create-remote-rule`: generate via the service (login) -- `%(TASKLESS_CLI)s agent check`: run every engine over the repo +- `%(RECIPE_FETCH)screate-legacy-rule`: author in a linter the repo already uses +- `%(RECIPE_FETCH)screate-sg-rule`: author a local ast-grep rule (no login) +- `%(RECIPE_FETCH)screate-vale-rule`: author a local Vale rule (no login) +- `%(RECIPE_FETCH)screate-runtime-rule`: the runtime tier, logged out +- `%(RECIPE_FETCH)screate-remote-rule`: generate via the service (login) +- `%(RECIPE_FETCH)scheck`: run every engine over the repo diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 9e14c012..440a1b1d 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -191,10 +191,13 @@ export interface RecipeOptions { */ invocation?: string; /** - * Render the steps that gather evidence by running this CLI. + * Render the steps that gather evidence by running this CLI, and the + * passages that name a recipe as a command to fetch. * - * `false` replaces them with a statement of what the caller must supply - * instead. It exists because `invocation` cannot do this job: that option + * `false` replaces the evidence steps with a statement of what the caller + * must supply instead, and reduces a recipe citation to its topic name: the + * route's final step names the destination rather than its fetch command, + * and `See Also` lists topics rather than invocations. It exists because `invocation` cannot do this job: that option * substitutes the BINARY NAME inside a command, so a consumer with no CLI * setting it to a phrase renders `Run: detect --json` — a * malformed instruction rather than a clean absence, and worse than either @@ -296,6 +299,33 @@ function runBlock( return `Run:\n \`\`\`\n ${invocation} ${command}\n \`\`\`\n ${connective}`; } +/** + * The route recipe's final step, as a whole numbered step including its title. + * + * The title is inside the substitution for the reason {@link hostToolsStep} + * gives: "Name the command" is the part that is wrong for a caller with no + * terminal, where the answer is the destination's topic name and a command + * would be one more thing to strip back out (taskless/cli#469). + */ +function nameDestinationStep(invocation: string | undefined): string { + if (invocation === undefined) { + return `**Name the destination.** Finish with the topic name of the +recipe that authors this rule: +\`\`\` +create-vale-rule +\`\`\` +A destination that is not one of the topic names above is a category, +and a category is not an answer.`; + } + return `**Name the command.** Finish by telling the user, or running, the +exact fetch for the destination you chose: +\`\`\` +${invocation} agent create-vale-rule +\`\`\` +A destination that is not a runnable command is a category, and a +category is not an answer.`; +} + /** * Indent every line after the first, so a multi-line block substituted into an * indented `%(KEY)s` is still the markdown unit it replaces. @@ -493,6 +523,18 @@ export function buildVariables( options.mechanics === false ? SUPPLIED_LOGIN : runBlock(resolveInvocation(options), "info --json", "and note"), + // A recipe named as something to fetch. Written against the topic name in + // the source (`%(RECIPE_FETCH)screate-sg-rule`), so with mechanics off the + // citation reduces to the name itself rather than to a command whose + // binary the caller does not have. + RECIPE_FETCH: + options.mechanics === false ? "" : `${resolveInvocation(options)} agent `, + NAME_DESTINATION: indentBlock( + nameDestinationStep( + options.mechanics === false ? undefined : resolveInvocation(options) + ), + " " + ), // Three steps, in descending order of how much the resolver actually // knows: the caller was told how the CLI was launched; the build is a // nightly/dev/self that knows what it is; nobody knows, so ask the agent. diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index f249336a..9a8d1f81 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -185,7 +185,9 @@ describe("the CLI invocation variable", () => { "DETECT_EVIDENCE", "HOST_TOOLS", "LOGIN_EVIDENCE", + "NAME_DESTINATION", "PACKAGE_MANAGER_DLX", + "RECIPE_FETCH", "SOURCE_PR_REVIEW", "TASKLESS_CLI", "VALE_COMMENT_FORMATS", @@ -382,6 +384,31 @@ describe("host mechanics suppression", () => { ); }); + it("names the destination by topic rather than by command", () => { + // taskless/cli#469. Step 8, the failure fallback and See Also all named a + // recipe as a command to fetch, so a caller with no terminal still got + // invocations after asking for none. The citation reduces to the topic + // name, which is the answer the step asks for. + const rendered = getPrompt("route", { mechanics: false }); + expect(rendered).toContain("**Name the destination.**"); + expect(rendered).not.toContain("**Name the command.**"); + expect(rendered).toMatch(/```\n {3}create-vale-rule\n {3}```/); + expect(rendered).toContain("- `create-sg-rule`: author a local ast-grep"); + expect(rendered).toContain("On yes, fetch\n `create-remote-rule`."); + expect(rendered).not.toContain(""); + expect(rendered).not.toContain(" agent create-"); + }); + + it("still renders every recipe citation as a command by default", () => { + const rendered = getPrompt("route", { invocation: "npx @taskless/cli" }); + expect(rendered).toContain("**Name the command.**"); + expect(rendered).toContain(" npx @taskless/cli agent create-vale-rule\n"); + expect(rendered).toContain( + "- `npx @taskless/cli agent create-sg-rule`: author a local ast-grep" + ); + expect(rendered).toContain("`npx @taskless/cli agent create-remote-rule`."); + }); + it("keeps the evidence itself, since the criteria are stated in terms of it", () => { // Dropping the steps entirely would lose the inputs, not just the // commands: the prose below each step describes what the evidence holds. From 695647dbf6e23dcd3c7f01b9410874681a29e6b3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 11:17:42 -0700 Subject: [PATCH 3/3] docs(prompts): rewrap the mechanics option comment --- packages/cli/src/prompts/recipes.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 440a1b1d..70143069 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -197,7 +197,9 @@ export interface RecipeOptions { * `false` replaces the evidence steps with a statement of what the caller * must supply instead, and reduces a recipe citation to its topic name: the * route's final step names the destination rather than its fetch command, - * and `See Also` lists topics rather than invocations. It exists because `invocation` cannot do this job: that option + * and `See Also` lists topics rather than invocations. + * + * It exists because `invocation` cannot do this job: that option * substitutes the BINARY NAME inside a command, so a consumer with no CLI * setting it to a phrase renders `Run: detect --json` — a * malformed instruction rather than a clean absence, and worse than either