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
7 changes: 7 additions & 0 deletions .changeset/prompts-host-invocation-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@taskless/cli": patch
---

`@taskless/cli/prompts` now renders the `<taskless-cli>` marker when the caller passes no `invocation`, on every build. A nightly used to substitute its own `npx @taskless/cli-nightly@<version>` 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 <topic>` 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.
22 changes: 8 additions & 14 deletions packages/cli/src/agent/route.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
21 changes: 19 additions & 2 deletions packages/cli/src/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getRawRecipe,
getRecipe,
getRenderedRecipe,
TASKLESS_CLI_MARKER,
type RecipeOptions,
type RecipeText,
} from "./recipes.js";
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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 <topic>` and wrong here. A host that imported this
* package never launched it, so a nightly's
* `npx @taskless/cli-nightly@<version>` 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`
Expand Down
57 changes: 52 additions & 5 deletions packages/cli/src/prompts/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const PACKAGE_MANAGER_DLX_MARKER = "<package-manager-dlx>";
* asked to supply the answer — so asking is strictly better than guessing a
* launcher the reader may not have.
*/
const TASKLESS_CLI_MARKER = "<taskless-cli>";
export const TASKLESS_CLI_MARKER = "<taskless-cli>";

/**
* One command-line tool a recipe can condition on, as the host measured it.
Expand Down Expand Up @@ -182,16 +182,24 @@ 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 "<taskless-cli>"
*/
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: <no CLI available> detect --json` — a
* malformed instruction rather than a clean absence, and worse than either
Expand Down Expand Up @@ -293,6 +301,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.
Expand Down Expand Up @@ -490,6 +525,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.
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/test/nightly/prompts-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -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@<version>`.
*
* `@taskless/cli/prompts` fell back to the BUILD's invocation when the caller
* passed none, so a nightly rendered `npx @taskless/cli-nightly@<version>`
* 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("<taskless-cli>");
});

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("<taskless-cli>");
});
}

it("still names the nightly itself on the CLI's own render path", () => {
// `taskless agent <topic>` 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`);
});
});
27 changes: 27 additions & 0 deletions packages/cli/test/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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("<taskless-cli>");
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.
Expand Down
Loading