From 94b48cf717de9f6e25565e641bca1162fedb2509 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 11:11:28 -0700 Subject: [PATCH 01/12] feat(cli): resolve the CLI invocation as a recipe variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a %(TASKLESS_CLI)s sprintf variable carrying the full command a reader would type to run this CLI, and expose the render path as two new public accessors on @taskless/cli/prompts. The variable resolves in three steps: an explicit RecipeOptions.invocation, else this build's own invocation when the build is not prod, else the agent-fill marker . Step three deliberately does not guess `npx @taskless/cli` — a prod build that was not told how it was launched does not know, and the recipes are read by an agent that can be asked. The value is an argument, never an ambient read. Detecting a launcher needs process.argv and process.env, and this module is imported by Workers without nodejs_compat where a module-scope `process` read throws at import time. assert-prompts-graph cannot catch that (process is a global, not an import), so the constraint is kept by shape: the CLI detects and passes it in. getInstructions/getRawInstructions return { text, variables }. The variable list comes from sprintf-js's own parse — rendering against a recording Proxy, since the library exports no parser — rather than a regex over the template, per the styleguide's "ask the thing that already parsed it". That pass's output is discarded: sprintf collapses %% to % irreversibly, so only the source template is re-renderable, which the new round-trip test pins. No recipe uses the variable yet; that is the next PR in the stack. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .changeset/full-cli-invocation.md | 11 ++ .../full-cli-invocation/.openspec.yaml | 2 + .../changes/full-cli-invocation/proposal.md | 45 ++++++ .../specs/cli-agent/spec.md | 86 ++++++++++ .../specs/cli-knowledge-prompts/spec.md | 75 +++++++++ .../full-cli-invocation/specs/cli/spec.md | 52 ++++++ openspec/changes/full-cli-invocation/tasks.md | 40 +++++ packages/cli/src/prompts/index.ts | 64 +++++++- packages/cli/src/prompts/recipes.ts | 152 +++++++++++++++++- packages/cli/src/util/invocation.ts | 22 ++- packages/cli/test/prompts.test.ts | 131 ++++++++++++++- 11 files changed, 667 insertions(+), 13 deletions(-) create mode 100644 .changeset/full-cli-invocation.md create mode 100644 openspec/changes/full-cli-invocation/.openspec.yaml create mode 100644 openspec/changes/full-cli-invocation/proposal.md create mode 100644 openspec/changes/full-cli-invocation/specs/cli-agent/spec.md create mode 100644 openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md create mode 100644 openspec/changes/full-cli-invocation/specs/cli/spec.md create mode 100644 openspec/changes/full-cli-invocation/tasks.md diff --git a/.changeset/full-cli-invocation.md b/.changeset/full-cli-invocation.md new file mode 100644 index 00000000..818d80f8 --- /dev/null +++ b/.changeset/full-cli-invocation.md @@ -0,0 +1,11 @@ +--- +"@taskless/cli": minor +--- + +Name the CLI by its full invocation everywhere an agent is told to run it. + +Agent recipes said `taskless agent route` — a binary almost nobody has on `PATH` — in 114 places, `npx @taskless/cli …` in 40 more, and only the second form was rewritten for non-prod builds. A nightly's recipes therefore sent readers to the released package. All of it now renders through one new sprintf variable, `%(TASKLESS_CLI)s`, which resolves to a caller-supplied invocation, else the build's own invocation when that build is not prod, else the agent-fill marker ``. + +`@taskless/cli/prompts` gains `getInstructions(topic, options?)` and `getRawInstructions(topic, options?)`, both returning `{ text, variables }`. The raw form hands back the unrendered template and the list of variables it contains, so a host that knows its own launcher can render the text itself; `variables` comes from sprintf-js's own parse rather than a regex over the template. `PromptOptions.invocation` is the only way a consumer sets `TASKLESS_CLI` — the render path stays free of `process` so it remains importable from a Worker. + +Fixes launcher detection in user-facing error messages. `getCliPrefix()` read only `npm_config_user_agent`, which every pnpm entry point sets, so running the CLI from a `package.json` script told the user to run `pnpm dlx @taskless/cli@latest`. Detection now reads the path the binary was launched from, recognizes npx and `pnpm dlx` only, and answers "unknown" for everything else. The package specifier comes from the build target, so a nightly's error messages name `@taskless/cli-nightly` at its own version. diff --git a/openspec/changes/full-cli-invocation/.openspec.yaml b/openspec/changes/full-cli-invocation/.openspec.yaml new file mode 100644 index 00000000..44f55ffe --- /dev/null +++ b/openspec/changes/full-cli-invocation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-23 diff --git a/openspec/changes/full-cli-invocation/proposal.md b/openspec/changes/full-cli-invocation/proposal.md new file mode 100644 index 00000000..9956be6f --- /dev/null +++ b/openspec/changes/full-cli-invocation/proposal.md @@ -0,0 +1,45 @@ +## Why + +A recipe that says `` `taskless agent route` `` is telling the reader to run a binary that is, for most readers, not on `PATH`. Nobody installs `@taskless/cli` globally — it is reached through `npx` or `pnpm dlx` — so the shortest correct form of that line is `npx @taskless/cli@latest agent route`, and the recipes say it 154 times in three different ways: + +| Form | Occurrences | Where | +| ------------------------- | ----------- | --------------------- | +| `` `taskless ` `` | 114 | 20 of 21 recipe files | +| `npx @taskless/cli ` | 40 | 13 recipe files | +| `%(PACKAGE_MANAGER_DLX)s` | 6 | `ci.txt` only | + +Three spellings of one fact is a drift surface, and it is already drifting: `applyCliInvocation` rewrites only the second form, so on a `nightly` or `build:self` artifact the 114 bare occurrences keep naming a binary that build deliberately did not install. An agent reading a nightly's `route` recipe is told to run `taskless agent create-sg-rule`, which resolves to whatever `@taskless/cli` happens to be on the machine — the released package, not the nightly it is supposed to be exercising. The `dev`/`self` build notice exists to paper over exactly this, and it papers over one form out of three. + +Underneath the recipes, the CLI's own answer to "how was I called" is wrong. `getCliPrefix()` (`packages/cli/src/util/package-manager.ts:8-20`) reads `npm_config_user_agent` and nothing else. Every pnpm entry point sets a `pnpm/` user agent — `pnpm run`, `pnpm exec`, `pnpm dlx`, and every lifecycle script — so a CLI invoked from a `package.json` script inside a pnpm repo tells the user to run `pnpm dlx @taskless/cli@latest auth login`. That is not how they got there and, in a repo that pins the CLI as a devDependency, not what they want. The function also hardcodes `@taskless/cli@latest`, so a nightly's error messages send the reader to the released package — the same bug the nightly build target was created to fix, in the one code path that never got the fix. + +A user agent cannot answer this question. It reports which package manager is in the process tree, not which command the user typed. What distinguishes `pnpm dlx` from `pnpm run` is the path the binary was launched from: `pnpm dlx` runs out of `~/Library/Caches/pnpm/dlx//…`, `npx` out of `~/.npm/_npx//…`, and `pnpm run` out of the repo's own `node_modules/.bin`. `process.argv[1]` carries that, and it is the signal this change reads. + +## What Changes + +- **Add a `%(TASKLESS_CLI)s` sprintf variable** carrying the full invocation — launcher, package name, and version pin — and normalize all 154 hardcoded call sites in `packages/cli/src/agent/*.txt`, `skills/taskless/SKILL.md`, and `commands/tskl/tskl.md` onto it. +- **Resolve it in three steps**: an explicit `RecipeOptions.invocation`, else the build-target invocation `__TASKLESS_CLI__` when that build is not prod, else the agent-fill marker `` — matching the `` convention already in the renderer. A prod build that cannot tell how it was launched emits a marker rather than a guess. +- **Detect the launcher from `process.argv[1]` and the environment, as a pure function over an injected context**, the way `resolveBuildTarget` is pure over an injected `BuildEnvironment`. It answers `npx`, `pnpm dlx`, or "not confident" — and "not confident" is a first-class answer, not a fallback to npx. Yarn, bun, and global installs are deliberately not detected: they set no signal distinguishable from a bare `node` launch, and the marker is the honest answer for them. +- **Fix `getCliPrefix()`** to use that detection and to derive its package spec from the build target, so a nightly's error messages name `@taskless/cli-nightly@`. Five call sites (`auth/identity.ts:29`, `auth/token.ts:121`, `api/rules.ts:40,79`, `commands/check.ts:172`) are corrected by the change with no edit. +- **Export `getInstructions` and `getRawInstructions`** from `@taskless/cli/prompts`, each returning `{ text, variables }`. `getRawInstructions` returns the unrendered template plus the list of variables it contains, so a host that knows its own package manager can render the text itself. Both throw on an unknown topic, matching `getPrompt`; `getRecipe` keeps returning `undefined` because the `agent` command and its tests depend on that. +- **Collect the variable list from sprintf's own parser, not a regex.** `sprintf-js` exports no parser, but its named-argument lookup is property access — so rendering against a `Proxy` makes sprintf report which names it asked for. Per `.conventions/STYLEGUIDE-CODE.md`, this asks the thing that already parsed the template instead of re-deriving it with a weaker tool. The proxy pass's _output_ is discarded: sprintf collapses `%%` to `%` irreversibly during parse, so only the source template is re-renderable. + +**The detection is passed in, never read.** `src/prompts/` must stay importable by a Worker without `nodejs_compat`, where a module-scope `process.env` read throws at import time. `assertPromptsGraph` in `vite.config.ts` would not catch that — `process` is a global, not an import — so the constraint is architectural: the CLI detects and passes `invocation`; a host that imports `@taskless/cli/prompts` passes nothing and gets the marker. + +## Capabilities + +### Modified Capabilities + +- `cli-agent`: the sprintf variable table gains `TASKLESS_CLI`, and recipes are required to name the CLI through it rather than as a bare binary or a hardcoded `npx` string. +- `cli-knowledge-prompts`: the export gains `getInstructions`/`getRawInstructions` and the `invocation` option, and the rendering guarantee extends to the new variable. +- `cli`: launcher detection becomes a specified behavior — argv-shaped, pure, and allowed to answer "unknown". + +## Impact + +- **Modified**: `packages/cli/src/prompts/recipes.ts` (variable table, raw/rendered split, variable collection), `packages/cli/src/prompts/index.ts` (two new exports), `packages/cli/src/util/package-manager.ts` (rewritten around argv detection), `packages/cli/src/commands/agent.ts` (passes the detected invocation), all 21 files under `packages/cli/src/agent/`, `skills/taskless/SKILL.md`, `commands/tskl/tskl.md`. +- **Tests**: `test/package-manager.test.ts` is rewritten — its premise (user agent is sufficient) is what this change refutes. `test/recipe-cross-references.test.ts` moves from scanning recipe _source_ to scanning _rendered_ recipes, because a `%(TASKLESS_CLI)s agent ` citation is invisible to a regex anchored on a package name. New: raw/rendered round-trip per topic, `%%` preservation, table-driven detection, prod-build-renders-a-marker, and a guard that no bare `` `taskless ` `` returns to the recipe sources. +- **Unchanged**: `getPrompt`, `PROMPTS`, `TOPICS`, `INTERNAL_TOPICS`, and every existing `PromptOptions` field. `getRecipe` keeps its `undefined`-on-unknown contract. +- **Out of scope**: detecting yarn and bun launchers; changing `PACKAGE_MANAGER_DLX`, which answers a different question (what the _consuming repo's_ CI should type) and stays a marker. + +**Delivery shape: stacked, merging DOWN, in five PRs.** The units are only correct together. A recipe carrying `%(TASKLESS_CLI)s` without the renderer fails `test/prompts.test.ts`'s unresolved-placeholder assertion, and the renderer alone ships a variable no recipe uses while `test/recipe-cross-references.test.ts` still scans source for a spelling that is being removed. There is no ordering in which an intermediate `main` is both green and coherent, so the stack merges down from the tip and reaches `main` as one merge. + +**Tracking:** taskless/cli#141 diff --git a/openspec/changes/full-cli-invocation/specs/cli-agent/spec.md b/openspec/changes/full-cli-invocation/specs/cli-agent/spec.md new file mode 100644 index 00000000..a45df682 --- /dev/null +++ b/openspec/changes/full-cli-invocation/specs/cli-agent/spec.md @@ -0,0 +1,86 @@ +## MODIFIED Requirements + +### Requirement: Recipe substitution uses sprintf-js named arguments + +Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named-argument form (`%(KEY)s`). The renderer SHALL build a variables table for each render call containing two flavors of substitution: + +1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic. +2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as ``). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention. + +The renderer SHALL additionally provide `TASKLESS_CLI` for every render. It is a hybrid of the two flavors: system-resolved when the caller or the build knows the answer, and an agent-fill marker when neither does. It SHALL resolve in this order: + +1. The caller-supplied invocation, when one is given. +2. The build-target invocation, when the build target is not prod — a `nightly`, `dev`, or `self` build knows exactly what it is and SHALL name itself. +3. Otherwise the agent-fill marker ``. + +Step 3 SHALL NOT fall back to `npx @taskless/cli`. A prod build that does not know how it was launched has no basis for naming one launcher over another, and a marker asks the reading agent for the answer instead of asserting a wrong one. + +Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table. + +#### Scenario: CLI_VERSION substitutes the build-time version + +- **WHEN** any recipe is rendered +- **THEN** every `%(CLI_VERSION)s` occurrence SHALL be replaced with the build-time CLI version + +#### Scenario: INPUT_SCHEMA substitutes only when present in the recipe + +- **WHEN** a recipe contains `%(INPUT_SCHEMA)s` +- **THEN** it SHALL be replaced with the JSON Schema rendered from the topic's Zod schema +- **AND** when no Zod schema is registered for the topic, the placeholder SHALL render as `(no input schema for this topic)` + +#### Scenario: PACKAGE_MANAGER_DLX renders as an agent-fill marker + +- **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s` +- **THEN** the rendered output SHALL contain the literal token `` at every occurrence + +#### Scenario: TASKLESS_CLI renders a caller-supplied invocation + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with an explicit invocation +- **THEN** every occurrence SHALL render as that invocation + +#### Scenario: TASKLESS_CLI names a non-prod build target + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with no explicit invocation from a `nightly`, `dev`, or `self` build +- **THEN** every occurrence SHALL render as that build's own invocation, so a nightly names `@taskless/cli-nightly` at its published version rather than the released package + +#### Scenario: TASKLESS_CLI falls back to an agent-fill marker + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered from a prod build with no explicit invocation +- **THEN** every occurrence SHALL render as the literal token `` +- **AND** SHALL NOT render as `npx @taskless/cli` or any other guessed launcher + +#### Scenario: No legacy placeholder syntax remains in recipes + +- **WHEN** any `.txt` file under `packages/cli/src/agent/` is read +- **THEN** it SHALL NOT contain a `{{KEY}}` mustache-style placeholder +- **AND** all substitution SHALL be expressed as `%(KEY)s` sprintf-js named arguments + +## ADDED Requirements + +### Requirement: Recipes name the CLI by its full invocation + +Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL express the CLI as `%(TASKLESS_CLI)s`, followed by the subcommand and its arguments. This applies to the recipe sources under `packages/cli/src/agent/`, to `skills/taskless/SKILL.md`, and to `commands/tskl/tskl.md`. + +Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers, and SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli`, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. + +An automated check SHALL fail when a bare `` `taskless ` `` invocation appears in a recipe source, so the normalization cannot silently regress as recipes are edited. + +#### Scenario: A recipe instructs the reader to run a subcommand + +- **WHEN** a recipe tells the reader to fetch another topic +- **THEN** the source SHALL read `%(TASKLESS_CLI)s agent ` rather than `taskless agent ` or `npx @taskless/cli agent ` + +#### Scenario: A regression is reintroduced + +- **WHEN** a recipe source is edited to contain a bare `` `taskless check` ``-style invocation +- **THEN** the automated check SHALL fail and name the offending file and line + +#### Scenario: Non-prod builds rewrite every invocation, not some of them + +- **WHEN** a `nightly` or `self` build renders any recipe +- **THEN** every CLI invocation in the rendered text SHALL name that build's own invocation, with no occurrence left naming the released `@taskless/cli` + +#### Scenario: Cross-reference checking survives the normalization + +- **WHEN** the check that recipe cross-references cite only topics that resolve is run +- **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder diff --git a/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md b/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..4042a477 --- /dev/null +++ b/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: The export provides rendered and raw instruction accessors + +The export SHALL provide `getInstructions(topic, options?)` and `getRawInstructions(topic, options?)`, each returning `{ text: string; variables: string[] }`. + +`getInstructions` SHALL return finished text — the same string `getPrompt` returns for the same topic and options — alongside the names of the sprintf variables the topic's template contains. `getRawInstructions` SHALL return the **unrendered** template text alongside the same variable names, so a host that knows a value the package cannot know can render it itself. + +Both SHALL throw on a topic with no embedded recipe, matching `getPrompt`. The existing internal `getRecipe` accessor SHALL continue to return `undefined` for an unknown topic; the `agent` command distinguishes an unknown topic from a failure and cannot use a throwing accessor. + +`variables` SHALL be identical between the two functions for the same topic, since both describe the same template. + +#### Scenario: Rendered instructions match the existing accessor + +- **WHEN** a consumer calls `getInstructions(t)` and `getPrompt(t)` for the same topic and options +- **THEN** `getInstructions(t).text` SHALL equal `getPrompt(t)` + +#### Scenario: Raw instructions are re-renderable + +- **WHEN** a consumer renders `getRawInstructions(t).text` with sprintf-js against the same variable values the package used +- **THEN** the result SHALL equal `getInstructions(t).text` + +#### Scenario: An unknown topic throws + +- **WHEN** either accessor is called with a topic that has no embedded recipe +- **THEN** it SHALL throw, rather than returning an empty or undefined result + +### Requirement: The variable list is derived from the template parser + +The `variables` list SHALL be obtained from `sprintf-js`'s own parse of the template, by rendering it against a value source that records which names are requested. It SHALL NOT be reconstructed by pattern-matching the template text. + +The recorded pass's rendered output SHALL be discarded. `sprintf-js` collapses an escaped `%%` to a literal `%` while parsing, so text that has been through it is no longer a valid template — `getRawInstructions().text` SHALL therefore be the source template verbatim, never the recording pass's output. + +#### Scenario: Escaped percent signs survive in raw text + +- **WHEN** a topic whose template contains `%%` is requested via `getRawInstructions` +- **THEN** the returned text SHALL still contain `%%`, and rendering it SHALL yield a single `%` + +#### Scenario: Variables are reported without a regex + +- **WHEN** the variable list for a topic is computed +- **THEN** it SHALL come from the templating library's parse, so a name the library resolves is reported and text that merely resembles a placeholder is not + +## MODIFIED Requirements + +### 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 `TASKLESS_CLI` from `PromptOptions.invocation`, the non-prod build-target invocation, 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. + +`PromptOptions.invocation` SHALL be the only way a consumer influences `TASKLESS_CLI`. The render path SHALL NOT read `process`, the environment, or `argv` to discover the answer for itself: the module is imported by Workers without `nodejs_compat`, where a module-scope `process` read throws at import time, and a build-graph check cannot catch that because `process` is a global rather than an import. Detection therefore lives in the CLI, which passes the result in. + +#### 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 recipe carrying `%(INPUT_SCHEMA)s` is rendered (today `create-remote-rule` and `improve-rule`, 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 `` marker; supplying `packageManagerDlx` substitutes that value instead + +#### Scenario: A host importing the export gets the marker + +- **WHEN** a consumer imports `@taskless/cli/prompts` from a prod build and renders a topic without supplying `invocation` +- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as ``, leaving the host free to substitute the launcher it actually offers + +#### Scenario: A supplied invocation is used verbatim + +- **WHEN** a consumer supplies `invocation: "pnpm dlx @taskless/cli@latest"` +- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as that string diff --git a/openspec/changes/full-cli-invocation/specs/cli/spec.md b/openspec/changes/full-cli-invocation/specs/cli/spec.md new file mode 100644 index 00000000..b3d20ae1 --- /dev/null +++ b/openspec/changes/full-cli-invocation/specs/cli/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: The CLI determines how it was launched from the path it was launched from + +The CLI SHALL determine the command a user would type to reach it again from the shape of `process.argv[1]` together with the process environment, and SHALL expose that determination as a function that is pure over an injected context — an environment record and an argv array — so every launcher case is testable without spawning a process. This mirrors `resolveBuildTarget`, which is pure over an injected `BuildEnvironment` for the same reason. + +The detection SHALL recognize two launchers: + +- **npx**, identified by an `_npx` cache path segment in `argv[1]`, or by the environment reporting an `exec` command whose lifecycle event is `npx`. +- **pnpm dlx**, identified by a `pnpm/` user agent together with a `dlx` cache path segment in `argv[1]`. + +The detection SHALL return "unknown" for everything else, and "unknown" SHALL be a first-class answer rather than a fallback to npx. In particular, `pnpm run`, `pnpm exec`, and pnpm lifecycle scripts all set a `pnpm/` user agent while running the CLI out of the repository's own `node_modules`; they SHALL NOT be reported as `pnpm dlx`. A bare `node` invocation, a `node_modules/.bin` shim, and a global install set no distinguishing signal and SHALL all be reported as unknown. + +Yarn and bun are deliberately not detected. They are distinguishable only by the user agent, which the pnpm case demonstrates is not evidence of how the user invoked anything. + +#### Scenario: An npx launch is recognized + +- **WHEN** the CLI is launched by `npx` and `argv[1]` lies under the npx cache +- **THEN** detection SHALL report an npx launch + +#### Scenario: A pnpm dlx launch is recognized + +- **WHEN** the CLI is launched by `pnpm dlx` and `argv[1]` lies under pnpm's dlx cache +- **THEN** detection SHALL report a pnpm dlx launch + +#### Scenario: A pnpm script is not mistaken for pnpm dlx + +- **WHEN** the CLI runs from a `package.json` script under pnpm, with a `pnpm/` user agent and an `argv[1]` inside the repository's `node_modules` +- **THEN** detection SHALL report unknown, not pnpm dlx + +#### Scenario: A bare launch is unknown + +- **WHEN** the CLI is launched with no package-manager environment at all +- **THEN** detection SHALL report unknown + +### Requirement: User-facing CLI invocations name the package the reader is running + +Wherever the CLI prints a command for the user to run — an authentication prompt, a re-authentication hint, an error remedy — it SHALL compose that command from the detected launcher and from the package specifier of the build in hand, not from a hardcoded string. + +The package specifier SHALL come from the build-target invocation, so a nightly build names `@taskless/cli-nightly` at its published version and a prod build names `@taskless/cli`. A prod specifier SHALL be pinned to `@latest` when it is handed to a launcher, since `npx` and `pnpm dlx` otherwise prefer whatever is already cached. A build whose invocation is a filesystem path (`dev`, `self`) SHALL be printed verbatim, since no launcher applies to it. + +Where detection reports unknown, the printed command SHALL name `npx` with the correct package specifier. That is a display default for a human reader who needs something runnable, and it is distinct from the recipe renderer's marker, which is read by an agent that can be asked to supply the right answer. + +#### Scenario: A nightly names itself in an error message + +- **WHEN** a nightly build prints an authentication remedy +- **THEN** the printed command SHALL name `@taskless/cli-nightly` at the nightly's own version, not `@taskless/cli` + +#### Scenario: A pnpm script no longer suggests pnpm dlx + +- **WHEN** the CLI runs from a `package.json` script under pnpm and prints an authentication remedy +- **THEN** the printed command SHALL NOT suggest `pnpm dlx`, because that is not how the reader reached the CLI diff --git a/openspec/changes/full-cli-invocation/tasks.md b/openspec/changes/full-cli-invocation/tasks.md new file mode 100644 index 00000000..64623ee2 --- /dev/null +++ b/openspec/changes/full-cli-invocation/tasks.md @@ -0,0 +1,40 @@ +Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branch and carries the OpenSpec proposal and the single changeset. Groups 2–5 stack above it, each based on its parent branch. The stack merges down from the tip and reaches `main` as one protected merge, because no intermediate state is green: a recipe carrying `%(TASKLESS_CLI)s` without the renderer fails the unresolved-placeholder assertion, and the renderer alone ships a variable no recipe uses. + +## 1. PR 1 (bottom) — the renderer, the variable, and the API + +- [x] 1.1 Add the OpenSpec change under `openspec/changes/full-cli-invocation/` and validate it with `pnpm openspec validate full-cli-invocation --strict` +- [x] 1.2 Add the changeset on this branch, **before cutting any child branch** — inheritance only runs forward in time, and a changeset written on a child never reaches the branches below it +- [x] 1.3 Add `TASKLESS_CLI_MARKER = ""` and the three-step resolution (`options.invocation` → non-prod `__TASKLESS_CLI__` → marker) to the variable table in `src/prompts/recipes.ts`. Export the prod-invocation constant from `src/util/invocation.ts` so `recipes.ts` can ask "is this build prod?" without restating the string +- [x] 1.4 Add `invocation?: string` to `RecipeOptions`, documented the way `packageManagerDlx` is +- [x] 1.5 Split rendering into a variables-table builder and a renderer, and add `collectVariables(template)`: render against a `Proxy` whose `get` trap records the requested name and whose `has` trap returns `true`, then **discard the rendered output**. `%%` has already collapsed to `%` in it +- [x] 1.6 Add `getRawRecipe(topic, options?)` beside `getRecipe`, returning `{ text, variables }` where `text` is `applyCliInvocation(source)` with the header handled identically to the rendered path. The invocation rewrite belongs in raw text: it is build-target substitution, not templating, and leaving it out would break the round-trip +- [x] 1.7 Export `getInstructions` and `getRawInstructions` from `src/prompts/index.ts`, both throwing on an unknown topic with the same packaging-fault message `getPrompt` uses. Wrap `getRecipe`/`getRawRecipe` — do not duplicate the render path +- [x] 1.8 Tests: round-trip (`sprintf(raw.text, vars) === rendered.text`) for every canonical topic; `%%` preserved in raw and collapsed in rendered; `variables` identical between the two accessors; a prod build with no `invocation` renders ``; a supplied `invocation` renders verbatim; both accessors throw on an unknown topic while `getRecipe` still returns `undefined` + +## 2. PR 2 — launcher detection and the `getCliPrefix` fix + +- [ ] 2.1 Rewrite `src/util/package-manager.ts` around `detectLauncher({ env, argv })`, pure over an injected context. Recognize npx (`_npx` path segment in `argv[1]`, or `npm_command === "exec"` with `npm_lifecycle_event === "npx"`) and pnpm dlx (`pnpm/` user agent **and** a `dlx` path segment). Everything else is `undefined` +- [ ] 2.2 Derive the package specifier from `__TASKLESS_CLI__`: a `npx `-prefixed invocation yields its specifier (pinned to `@latest` when it carries no version), and a path-form invocation is returned verbatim with no launcher applied +- [ ] 2.3 Reimplement `getCliPrefix()` over the two, keeping `npx ` as the display default when detection is unknown. The five call sites are unchanged +- [ ] 2.4 Pass `invocation: detectCliInvocation(...)` from `src/commands/agent.ts` into `getRecipe` +- [ ] 2.5 Rewrite `test/package-manager.test.ts` as a table over injected contexts — its current premise, that the user agent answers the question, is what this change refutes. Cover npx by path, npx by env, pnpm dlx, pnpm run (must not be dlx), pnpm exec, a `node_modules/.bin` shim, and an empty environment +- [ ] 2.6 Confirm `test/prompts.test.ts`'s spawned-CLI byte-parity still holds: the test spawns `node dist/index.js` under a pnpm-run environment, which detection must report as unknown, so the CLI and the export both render the marker + +## 3. PR 3 — recipe normalization, part A + +- [ ] 3.1 Replace every bare `` `taskless ` `` and every `npx @taskless/cli ` with `%(TASKLESS_CLI)s ` across the heaviest recipes. Leave prose mentions of the product, `taskless.config`, and `.taskless/` alone +- [ ] 3.2 Leave `ci.txt`'s `%(PACKAGE_MANAGER_DLX)s` occurrences in place — they answer what the _consuming repo's_ CI should type, which is a different question from how this process was launched. Its one prose reference to `npx @taskless/cli` becomes `%(TASKLESS_CLI)s` +- [ ] 3.3 Note in the PR body that `onboard.txt` conflicts with PR #142, which lands first + +## 4. PR 4 — recipe normalization, part B + +- [ ] 4.1 Normalize the remaining recipes the same way +- [ ] 4.2 Normalize `skills/taskless/SKILL.md` and `commands/tskl/tskl.md`. These are not rendered through the recipe renderer, so they keep the literal `npx @taskless/cli` that `applyCliInvocation` already rewrites — verify which of the two treatments each file needs rather than assuming +- [ ] 4.3 Verify no recipe still names a bare `taskless` binary + +## 5. PR 5 (tip) — cross-reference checking and the archive + +- [ ] 5.1 Move `test/recipe-cross-references.test.ts` from scanning recipe source to scanning rendered recipes, where the invocation is a stable literal. Match the marker alongside the existing spellings +- [ ] 5.2 Add the regression guard: no bare `` `taskless ` `` in any `src/agent/*.txt`, naming the offending file and line +- [ ] 5.3 Archive the change to `openspec/changes/archive/-full-cli-invocation/` +- [ ] 5.4 Run every gate on the tip: build, test, typecheck, lint, `openspec validate --strict` diff --git a/packages/cli/src/prompts/index.ts b/packages/cli/src/prompts/index.ts index 8f8c97a2..5d2caf84 100644 --- a/packages/cli/src/prompts/index.ts +++ b/packages/cli/src/prompts/index.ts @@ -5,7 +5,13 @@ // `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"; +import { + getRawRecipe, + getRecipe, + getRenderedRecipe, + type RecipeOptions, + type RecipeText, +} from "./recipes.js"; /** * Public entry for `@taskless/cli/prompts`. @@ -91,13 +97,63 @@ export type PromptOptions = RecipeOptions; * {@link TOPICS} and the recipe files have diverged. */ export function getPrompt(topic: PromptTopic, options?: PromptOptions): string { - const rendered = getRecipe(topic, options); - if (rendered === undefined) { + return required(getRecipe(topic, options), topic); +} + +/** + * A prompt's text together with the sprintf variable names its template + * contains. The names come from `sprintf-js`'s own parse, not from a pattern + * match over the text. + */ +export type Instructions = RecipeText; + +/** + * Render a prompt and report which variables its template carries. + * + * `text` is byte-identical to {@link getPrompt} for the same arguments; the + * addition is `variables`, which tells a consumer what this topic's template + * was parameterized by without making them parse it. + * + * @throws when the topic has no canonical recipe in the build. + */ +export function getInstructions( + topic: PromptTopic, + options?: PromptOptions +): Instructions { + return required(getRenderedRecipe(topic, options), topic); +} + +/** + * The **unrendered** template for a prompt, plus the variables it contains. + * + * Use this when the host knows a value the package cannot: which launcher the + * reader will actually use, which package manager the target repository runs. + * Render it with `sprintf-js`'s named-argument form; the text is the source + * template verbatim, so its `%%` escapes are intact and it is safe to render + * exactly once. + * + * @throws when the topic has no canonical recipe in the build. + */ +export function getRawInstructions( + topic: PromptTopic, + options?: PromptOptions +): Instructions { + return required(getRawRecipe(topic, options), topic); +} + +/** + * Turn a missing topic into the same packaging-fault error {@link getPrompt} + * raises. `getRecipe` keeps its `undefined` contract because the `agent` + * command must tell an unknown topic apart from a failure; the public + * accessors do not, because an unknown `PromptTopic` cannot type-check. + */ +function required(value: T | undefined, topic: string): T { + if (value === undefined) { throw new Error( `No recipe is embedded for prompt topic "${topic}". This is a packaging fault: TOPICS lists a topic with no agent/${topic}.txt behind it.` ); } - return rendered; + return value; } /** Every exported topic as a render function, keyed by topic name. */ diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 04aff9e1..39309b11 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -1,7 +1,11 @@ import { sprintf } from "sprintf-js"; import { z } from "zod"; -import { applyCliInvocation } from "../util/invocation"; +import { + applyCliInvocation, + buildInvocation, + isProductionInvocation, +} from "../util/invocation"; import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; @@ -65,6 +69,16 @@ const TOPIC_INPUT_SCHEMAS: Record = { /** Agent-fill marker used when the caller does not supply a real value. */ const PACKAGE_MANAGER_DLX_MARKER = ""; +/** + * Agent-fill marker for the CLI invocation itself. + * + * Deliberately NOT `npx @taskless/cli`. A prod build that was not told how it + * was launched does not know, and the recipes are read by an agent that can be + * asked to supply the answer — so asking is strictly better than guessing a + * launcher the reader may not have. + */ +const TASKLESS_CLI_MARKER = ""; + /** Options accepted by the shared render path. */ export interface RecipeOptions { /** @@ -82,6 +96,26 @@ export interface RecipeOptions { * @default "" */ packageManagerDlx?: string; + /** + * Value substituted for the `%(TASKLESS_CLI)s` placeholder: the full command + * a reader would type to run this CLI, launcher and package specifier + * included (`npx @taskless/cli@latest`, `pnpm dlx @taskless/cli-nightly@…`). + * + * THIS IS AN ARGUMENT, NEVER AN AMBIENT READ. Detecting the launcher needs + * `process.argv` and `process.env`, and this module is imported by Workers + * without `nodejs_compat`, where a module-scope `process` read throws at + * import time. `assert-prompts-graph` in `vite.config.ts` would not catch it + * either — `process` is a global, not an import — so the constraint is kept + * by shape: the CLI detects and passes the value in (see + * `src/util/package-manager.ts`), and a host that imports + * `@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. + * + * @default "" + */ + invocation?: string; /** * Include the `# Topic: (CLI v / topic vN)` first line. * Suppressing it drops the CLI version from the text, which matters to @@ -104,15 +138,21 @@ export interface RecipeOptions { * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as * `` so the consuming agent knows to substitute. */ -function renderRecipe( +export function buildVariables( content: string, topic: string, options: RecipeOptions = {} -): string { +): Record { const variables: Record = { CLI_VERSION: __VERSION__, PACKAGE_MANAGER_DLX: options.packageManagerDlx ?? PACKAGE_MANAGER_DLX_MARKER, + // 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. + TASKLESS_CLI: + options.invocation ?? + (isProductionInvocation() ? TASKLESS_CLI_MARKER : buildInvocation()), }; if (content.includes("%(INPUT_SCHEMA)s")) { const schema = TOPIC_INPUT_SCHEMAS[topic]; @@ -120,7 +160,60 @@ function renderRecipe( ? JSON.stringify(z.toJSONSchema(schema), null, 2) : "(no input schema for this topic)"; } - const rendered = sprintf(applyCliInvocation(content), variables); + return variables; +} + +/** + * The sprintf variable names a template actually contains, in the order + * sprintf-js asks for them, de-duplicated. + * + * ASKS THE PARSER, DOES NOT RE-DERIVE IT. `sprintf-js` exports no parser + * (`sprintf`/`vsprintf` only), but its named-argument lookup is plain property + * access on the value object — so rendering against a `Proxy` that records + * every key it is asked for makes sprintf's own parse report the variable + * list. A regex over the template would be the weaker tool + * `.conventions/STYLEGUIDE-CODE.md` forbids here: it would report names inside + * a fenced example the parser never reaches, and would miss anything the + * library's grammar accepts that the pattern does not. + * + * THE RENDERED OUTPUT OF THIS PASS IS DISCARDED, and must be. sprintf collapses + * an escaped `%%` to a literal `%` while parsing, irreversibly — text that has + * been through it is no longer a valid template, so it can never be what + * {@link getRawRecipe} hands back. + */ +function collectVariables(template: string): string[] { + const seen = new Set(); + const recorder = new Proxy( + {}, + { + get(_target, key) { + if (typeof key === "string") seen.add(key); + return ""; + }, + has() { + return true; + }, + } + ); + sprintf(template, recorder); + return [...seen]; +} + +/** A recipe's text plus the sprintf variables its template contains. */ +export interface RecipeText { + text: string; + variables: string[]; +} + +function renderRecipe( + content: string, + topic: string, + options: RecipeOptions = {} +): string { + const rendered = sprintf( + applyCliInvocation(content), + buildVariables(content, topic, options) + ); return options.header === false ? stripHeader(rendered) : rendered; } @@ -158,9 +251,56 @@ export function getRecipe( topic: string, options: RecipeOptions = {} ): string | undefined { - const content = options.anonymous + const content = lookupRecipe(topic, options); + if (content === undefined) return undefined; + return renderRecipe(content, topic, options); +} + +/** The embedded source text for a topic, honoring the anonymous fallback. */ +function lookupRecipe( + topic: string, + options: RecipeOptions +): string | undefined { + return options.anonymous ? (anonymousMap.get(topic) ?? recipeMap.get(topic)) : recipeMap.get(topic); +} + +/** + * The **unrendered** template for a topic, plus the variables it contains. + * + * `text` is the source recipe with the build-target invocation rewrite applied + * and nothing else. The rewrite belongs here: it is build-target substitution + * rather than templating, and omitting it would make the raw text render to + * something the CLI never emits. Every `%(KEY)s` is left standing so a host + * that knows a value this package cannot know — its own launcher, its own + * package manager — can render the text itself. + * + * Returns `undefined` for an unknown topic, matching {@link getRecipe}. The + * public accessors in `./index.ts` turn that into a throw. + */ +export function getRawRecipe( + topic: string, + options: RecipeOptions = {} +): RecipeText | undefined { + const content = lookupRecipe(topic, options); if (content === undefined) return undefined; - return renderRecipe(content, topic, options); + const template = applyCliInvocation(content); + return { + text: options.header === false ? stripHeader(template) : template, + variables: collectVariables(template), + }; +} + +/** A topic's rendered text plus the variables its template contains. */ +export function getRenderedRecipe( + topic: string, + options: RecipeOptions = {} +): RecipeText | undefined { + const content = lookupRecipe(topic, options); + if (content === undefined) return undefined; + return { + text: renderRecipe(content, topic, options), + variables: collectVariables(applyCliInvocation(content)), + }; } diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index 54f8a737..d3797352 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -7,7 +7,25 @@ * installed rather than the released one. See `scripts/build-target.ts`, * `vite.config.ts`, and the root `package.json` scripts. */ -const PROD_INVOCATION = "npx @taskless/cli"; +export const PROD_INVOCATION = "npx @taskless/cli"; + +/** + * Whether this build's invocation is the released one. + * + * `false` means the build knows exactly what it is — a `nightly` pinned to its + * published version, or a `dev`/`self` path — and its instructions must say so + * rather than naming `@taskless/cli`. `true` means the build is the released + * package and has no idea how it was launched, which is a different situation + * from knowing it was launched as `npx @taskless/cli`. + */ +export function isProductionInvocation(): boolean { + return __TASKLESS_CLI__ === PROD_INVOCATION; +} + +/** This build's invocation, whatever the target. */ +export function buildInvocation(): string { + return __TASKLESS_CLI__; +} /** * Rewrite the canonical `npx @taskless/cli` invocation to the build-target @@ -21,7 +39,7 @@ const PROD_INVOCATION = "npx @taskless/cli"; * `npx @taskless/cli-nightly@@latest`). */ export function applyCliInvocation(content: string): string { - if (__TASKLESS_CLI__ === PROD_INVOCATION) return content; + if (isProductionInvocation()) return content; return content .replaceAll(`${PROD_INVOCATION}@latest`, __TASKLESS_CLI__) .replaceAll(PROD_INVOCATION, __TASKLESS_CLI__); diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 162b8217..1ed216e7 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -5,14 +5,24 @@ import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { sprintf } from "sprintf-js"; + import { PROMPTS, TOPICS, INTERNAL_TOPICS, + getInstructions, getPrompt, + getRawInstructions, type PromptOptions, } from "../src/prompts/index"; -import { canonicalRecipeTopics, getRecipe } from "../src/prompts/recipes"; +import { + buildVariables, + canonicalRecipeTopics, + getRawRecipe, + getRecipe, + getRenderedRecipe, +} from "../src/prompts/recipes"; const execFileAsync = promisify(execFile); @@ -93,6 +103,125 @@ describe("prompt rendering", () => { }); }); +/** + * The `TASKLESS_CLI` value for a set of options. Resolution is asserted on the + * variables table rather than on rendered recipe text: the table is where the + * three-step decision is made, and its answer is the same whether or not any + * given recipe happens to cite the CLI. + */ +function invocationVariable(options?: PromptOptions): string { + return buildVariables("", "any-topic", options).TASKLESS_CLI!; +} + +describe("the CLI invocation variable", () => { + const TASKLESS_CLI = invocationVariable; + + it("renders the agent-fill marker from a prod build with no invocation", () => { + // The test build is prod, so no build-target invocation is available and + // nothing was passed in. The marker is the correct answer; `npx + // @taskless/cli` would be a guess about a launcher the reader may not use. + expect(TASKLESS_CLI()).toBe(""); + expect(TASKLESS_CLI({})).toBe(""); + }); + + it("renders a supplied invocation verbatim", () => { + expect(TASKLESS_CLI({ invocation: "pnpm dlx @taskless/cli@latest" })).toBe( + "pnpm dlx @taskless/cli@latest" + ); + expect(TASKLESS_CLI({ invocation: "node dist/index.js" })).toBe( + "node dist/index.js" + ); + }); + + it("is provided on every render, alongside the other markers", () => { + const table = buildVariables("", "any-topic"); + expect(Object.keys(table).toSorted()).toEqual([ + "CLI_VERSION", + "PACKAGE_MANAGER_DLX", + "TASKLESS_CLI", + ]); + // INPUT_SCHEMA stays conditional on the placeholder being present. + expect(buildVariables("%(INPUT_SCHEMA)s", "improve-rule")).toHaveProperty( + "INPUT_SCHEMA" + ); + }); +}); + +describe("raw and rendered instructions", () => { + it("round-trips: rendering the raw template reproduces the rendered text", async () => { + for (const topic of await canonicalTopicsOnDisk()) { + const raw = getRawRecipe(topic); + const rendered = getRenderedRecipe(topic); + expect(raw, `no raw recipe for ${topic}`).toBeDefined(); + expect(rendered, `no rendered recipe for ${topic}`).toBeDefined(); + expect( + sprintf(raw!.text, buildVariables(raw!.text, topic)), + `${topic} does not round-trip` + ).toBe(rendered!.text); + } + }); + + it("reports the same variables from both forms, and only real ones", async () => { + for (const topic of await canonicalTopicsOnDisk()) { + const raw = getRawRecipe(topic)!; + expect(getRenderedRecipe(topic)!.variables.toSorted()).toEqual( + raw.variables.toSorted() + ); + // Every name sprintf asked for must be one the renderer can answer, or + // the recipe carries a placeholder nothing resolves. + const table = buildVariables(raw.text, topic); + for (const name of raw.variables) { + expect(table, `${topic} names an unresolvable ${name}`).toHaveProperty( + name + ); + } + } + }); + + it("keeps `%%` escaped in raw text and collapses it once rendered", async () => { + // sprintf collapses `%%` to `%` irreversibly while parsing, which is why + // the raw text must be the source template and never the output of the + // variable-collecting pass. + const topics = await canonicalTopicsOnDisk(); + const escaped = topics + .map((topic) => ({ topic, raw: getRawRecipe(topic)!.text })) + .filter(({ raw }) => raw.includes("%%")); + + expect(escaped.length, "no recipe exercises a `%%` escape").toBeGreaterThan( + 0 + ); + for (const { topic, raw } of escaped) { + const rendered = getRecipe(topic) ?? ""; + expect(rendered, `${topic} kept its escape`).not.toContain("%%"); + expect(sprintf(raw, buildVariables(raw, topic))).toBe(rendered); + } + }); + + it("exposes both forms as public API and matches getPrompt", () => { + for (const topic of TOPICS) { + expect(getInstructions(topic).text).toBe(getPrompt(topic)); + expect(getRawInstructions(topic).variables).toEqual( + getInstructions(topic).variables + ); + // Raw is a template, rendered is not. + expect(getRawInstructions(topic).text).not.toBe( + getInstructions(topic).text + ); + } + }); + + it("throws on an unknown topic while getRecipe still returns undefined", () => { + // @ts-expect-error "no-such-topic" is not a member of PromptTopic + expect(() => getInstructions("no-such-topic")).toThrow(/packaging fault/); + // @ts-expect-error "no-such-topic" is not a member of PromptTopic + expect(() => getRawInstructions("no-such-topic")).toThrow( + /packaging fault/ + ); + expect(getRawRecipe("no-such-topic")).toBeUndefined(); + expect(getRenderedRecipe("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("create-sg-rule"); From 6a3249c2dbc3f778eb2d22881c1b95fc31086f3d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 12:54:58 -0700 Subject: [PATCH 02/12] fix(cli): report header-less recipe variables from the returned text getRawRecipe and getRenderedRecipe collected `variables` from the full template while `text` was header-stripped under `{ header: false }`. Every recipe header carries %(CLI_VERSION)s, so both accessors reported a variable their own text no longer contained. Both now collect from the same slice of the template that `text` reflects. getRenderedRecipe also stops rewriting the invocation twice per call: the rewritten template is computed once and passed to the new renderTemplate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .../specs/cli-knowledge-prompts/spec.md | 9 ++++- packages/cli/src/prompts/recipes.ts | 38 ++++++++++++++---- packages/cli/test/prompts.test.ts | 39 +++++++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md b/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md index 4042a477..31fff2f8 100644 --- a/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md @@ -8,7 +8,9 @@ The export SHALL provide `getInstructions(topic, options?)` and `getRawInstructi Both SHALL throw on a topic with no embedded recipe, matching `getPrompt`. The existing internal `getRecipe` accessor SHALL continue to return `undefined` for an unknown topic; the `agent` command distinguishes an unknown topic from a failure and cannot use a throwing accessor. -`variables` SHALL be identical between the two functions for the same topic, since both describe the same template. +`variables` SHALL be identical between the two functions for the same topic and options, since both describe the same template. + +`variables` SHALL describe the `text` that accessor returns. When `header: false` drops the header line, variables that appear only in that line (every recipe header carries `%(CLI_VERSION)s`) SHALL NOT be reported. #### Scenario: Rendered instructions match the existing accessor @@ -20,6 +22,11 @@ Both SHALL throw on a topic with no embedded recipe, matching `getPrompt`. The e - **WHEN** a consumer renders `getRawInstructions(t).text` with sprintf-js against the same variable values the package used - **THEN** the result SHALL equal `getInstructions(t).text` +#### Scenario: A header-less accessor does not report header-only variables + +- **WHEN** a consumer calls either accessor with `{ header: false }` for a topic whose only `%(CLI_VERSION)s` placeholder is in the header line +- **THEN** `variables` SHALL NOT contain `CLI_VERSION` + #### Scenario: An unknown topic throws - **WHEN** either accessor is called with a topic that has no embedded recipe diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 39309b11..a5579466 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -210,10 +210,21 @@ function renderRecipe( topic: string, options: RecipeOptions = {} ): string { - const rendered = sprintf( - applyCliInvocation(content), - buildVariables(content, topic, options) - ); + return renderTemplate(applyCliInvocation(content), topic, options); +} + +/** + * Render an already-invocation-rewritten template. Split out from + * {@link renderRecipe} so a caller that needs the rewritten template for + * something else — {@link getRenderedRecipe}, which also reports the + * template's variables — rewrites once and passes it in. + */ +function renderTemplate( + template: string, + topic: string, + options: RecipeOptions = {} +): string { + const rendered = sprintf(template, buildVariables(template, topic, options)); return options.header === false ? stripHeader(rendered) : rendered; } @@ -286,9 +297,14 @@ export function getRawRecipe( const content = lookupRecipe(topic, options); if (content === undefined) return undefined; const template = applyCliInvocation(content); + // `variables` describes the string we hand back, so it is collected from the + // post-strip text — not the full template. Every header line carries + // %(CLI_VERSION)s, so collecting before the strip would report a variable + // the returned `text` no longer contains. + const text = options.header === false ? stripHeader(template) : template; return { - text: options.header === false ? stripHeader(template) : template, - variables: collectVariables(template), + text, + variables: collectVariables(text), }; } @@ -299,8 +315,14 @@ export function getRenderedRecipe( ): RecipeText | undefined { const content = lookupRecipe(topic, options); if (content === undefined) return undefined; + const template = applyCliInvocation(content); + // Variables come from the *template*, never from the rendered text — sprintf + // has already collapsed `%%` to a literal `%` there — but from the same slice + // of it that `text` reflects, so a header-less rendering does not report the + // header's %(CLI_VERSION)s. + const source = options.header === false ? stripHeader(template) : template; return { - text: renderRecipe(content, topic, options), - variables: collectVariables(applyCliInvocation(content)), + text: renderTemplate(template, topic, options), + variables: collectVariables(source), }; } diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 1ed216e7..dd11e8f3 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -178,6 +178,45 @@ describe("raw and rendered instructions", () => { } }); + it("reports variables for the text it returns when the header is dropped", async () => { + // The header line carries %(CLI_VERSION)s, so a header-less accessor must + // not keep reporting a variable its own `text` no longer contains. + const topics = await canonicalTopicsOnDisk(); + let droppedSomewhere = false; + + for (const topic of topics) { + const raw = getRawRecipe(topic, { header: false })!; + const rendered = getRenderedRecipe(topic, { header: false })!; + + expect( + rendered.variables.toSorted(), + `${topic} raw/rendered differ` + ).toEqual(raw.variables.toSorted()); + for (const name of raw.variables) { + expect( + raw.text, + `${topic} reports ${name} but the text has no placeholder for it` + ).toContain(`%(${name})`); + } + + const withHeader = getRawRecipe(topic)!.variables; + expect( + withHeader, + `${topic} gained a variable by dropping the header` + ).toEqual(expect.arrayContaining(raw.variables)); + if ( + withHeader.includes("CLI_VERSION") && + !raw.variables.includes("CLI_VERSION") + ) { + droppedSomewhere = true; + } + } + + expect(droppedSomewhere, "no recipe exercises a header-only variable").toBe( + true + ); + }); + it("keeps `%%` escaped in raw text and collapses it once rendered", async () => { // sprintf collapses `%%` to `%` irreversibly while parsing, which is why // the raw text must be the source template and never the output of the From 578c6d264414b1bf5b909b8209310c4dc6c6ac03 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 11:14:00 -0700 Subject: [PATCH 03/12] fix(cli): detect the launcher from argv, not the user agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCliPrefix() read npm_config_user_agent and nothing else. Every pnpm entry point sets a pnpm/ agent — pnpm run, pnpm exec, pnpm dlx, and every lifecycle script — so running the CLI from a package.json script told the developer to `pnpm dlx @taskless/cli@latest auth login`, reinstalling a CLI they already had pinned. The user agent reports which package manager is in the process tree, not which command anyone typed. What does distinguish them is where the binary lives: npx runs out of …/.npm/_npx//…, pnpm dlx out of pnpm's dlx cache, and pnpm run out of the repository's own node_modules/.bin. Detection now reads argv[1] for that, with the user agent demoted to a corroborating signal for the dlx case. `undefined` is a first-class answer. A node_modules/.bin shim, a bare node launch, a global install, yarn, and bun are mutually indistinguishable, so detection declines rather than guessing. Yarn and bun are deliberately not recognized — the user agent is the only thing that separates them, and the pnpm case is the proof that it is not evidence. Two callers, two different fallbacks, which is why they are separate functions. detectCliInvocation returns undefined so the recipe renderer can emit its agent-fill marker and let the reading agent supply the launcher it actually has. getCliPrefix always returns something runnable, because a person reading an error message needs a command, and npx resolves everywhere. The package specifier now comes from the build target rather than a hardcoded string, so a nightly's error messages name @taskless/cli-nightly at its own pinned version instead of sending readers to the released package. All five call sites are corrected without an edit. The old test suite is replaced rather than extended: its premise — that the user agent answers the question — is what this commit refutes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- openspec/changes/full-cli-invocation/tasks.md | 12 +- packages/cli/src/commands/agent.ts | 15 +- packages/cli/src/util/package-manager.ts | 162 +++++++++++++-- packages/cli/test/package-manager.test.ts | 191 +++++++++++++++--- 4 files changed, 329 insertions(+), 51 deletions(-) diff --git a/openspec/changes/full-cli-invocation/tasks.md b/openspec/changes/full-cli-invocation/tasks.md index 64623ee2..f71b55ee 100644 --- a/openspec/changes/full-cli-invocation/tasks.md +++ b/openspec/changes/full-cli-invocation/tasks.md @@ -13,12 +13,12 @@ Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branc ## 2. PR 2 — launcher detection and the `getCliPrefix` fix -- [ ] 2.1 Rewrite `src/util/package-manager.ts` around `detectLauncher({ env, argv })`, pure over an injected context. Recognize npx (`_npx` path segment in `argv[1]`, or `npm_command === "exec"` with `npm_lifecycle_event === "npx"`) and pnpm dlx (`pnpm/` user agent **and** a `dlx` path segment). Everything else is `undefined` -- [ ] 2.2 Derive the package specifier from `__TASKLESS_CLI__`: a `npx `-prefixed invocation yields its specifier (pinned to `@latest` when it carries no version), and a path-form invocation is returned verbatim with no launcher applied -- [ ] 2.3 Reimplement `getCliPrefix()` over the two, keeping `npx ` as the display default when detection is unknown. The five call sites are unchanged -- [ ] 2.4 Pass `invocation: detectCliInvocation(...)` from `src/commands/agent.ts` into `getRecipe` -- [ ] 2.5 Rewrite `test/package-manager.test.ts` as a table over injected contexts — its current premise, that the user agent answers the question, is what this change refutes. Cover npx by path, npx by env, pnpm dlx, pnpm run (must not be dlx), pnpm exec, a `node_modules/.bin` shim, and an empty environment -- [ ] 2.6 Confirm `test/prompts.test.ts`'s spawned-CLI byte-parity still holds: the test spawns `node dist/index.js` under a pnpm-run environment, which detection must report as unknown, so the CLI and the export both render the marker +- [x] 2.1 Rewrite `src/util/package-manager.ts` around `detectLauncher({ env, argv })`, pure over an injected context. Recognize npx (`_npx` path segment in `argv[1]`, or `npm_command === "exec"` with `npm_lifecycle_event === "npx"`) and pnpm dlx (`pnpm/` user agent **and** a `dlx` path segment). Everything else is `undefined` +- [x] 2.2 Derive the package specifier from `__TASKLESS_CLI__`: a `npx `-prefixed invocation yields its specifier (pinned to `@latest` when it carries no version), and a path-form invocation is returned verbatim with no launcher applied +- [x] 2.3 Reimplement `getCliPrefix()` over the two, keeping `npx ` as the display default when detection is unknown. The five call sites are unchanged +- [x] 2.4 Pass `invocation: detectCliInvocation(...)` from `src/commands/agent.ts` into `getRecipe` +- [x] 2.5 Rewrite `test/package-manager.test.ts` as a table over injected contexts — its current premise, that the user agent answers the question, is what this change refutes. Cover npx by path, npx by env, pnpm dlx, pnpm run (must not be dlx), pnpm exec, a `node_modules/.bin` shim, and an empty environment +- [x] 2.6 Confirm `test/prompts.test.ts`'s spawned-CLI byte-parity still holds: the test spawns `node dist/index.js` under a pnpm-run environment, which detection must report as unknown, so the CLI and the export both render the marker ## 3. PR 3 — recipe normalization, part A diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index a769decd..e8a6beb1 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -9,6 +9,10 @@ import { import { getTelemetry } from "../telemetry"; import { getRecipe } from "../prompts/recipes"; +import { + detectCliInvocation, + processLauncherContext, +} from "../util/package-manager"; // Recipe-only topics (no backing subcommand) that should still be // discoverable from the `taskless agent` index. The rule-authoring front @@ -144,7 +148,16 @@ export function createAgentCommand(subCommands: SubCommandsDef) { // --anonymous is set, fall back to the canonical recipe. The lookup and // the render both live in the shared prompts module, so `agent` and the // `@taskless/cli/prompts` export emit the same text. - const recipe = getRecipe(key, { anonymous: args.anonymous }); + // + // The invocation is detected HERE and passed in, never read inside the + // prompts module: that module is imported by Workers without + // `nodejs_compat`, where a module-scope `process` read throws at import + // time. When the launcher is unknown the value is `undefined` and the + // renderer falls back to its agent-fill marker. + const recipe = getRecipe(key, { + anonymous: args.anonymous, + invocation: detectCliInvocation(processLauncherContext()), + }); if (recipe) { // cli_agent: agent fetched a specific recipe (intent signal). The topic diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 218ebafd..525f5456 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -1,20 +1,152 @@ +import { buildInvocation } from "./invocation"; + /** - * Detect the package manager that invoked the CLI from the - * npm_config_user_agent environment variable and return the - * appropriate `dlx`-style prefix for error messages. + * How the CLI answers "what would the reader type to run me again?" * - * Falls back to `npx` when detection is not possible. + * The answer has two halves that come from different places. The **package + * specifier** (`@taskless/cli@latest`, `@taskless/cli-nightly@0.11.0-…`) is a + * build-time fact, baked in by `scripts/build-target.ts`. The **launcher** + * (`npx`, `pnpm dlx`) is a runtime fact, and the only honest source for it is + * the path this process was launched from. + * + * IT IS NOT THE USER AGENT. `npm_config_user_agent` reports which package + * manager is in the process tree, not which command anyone typed: `pnpm run`, + * `pnpm exec`, `pnpm dlx`, and every pnpm lifecycle script all set `pnpm/…`. + * Reading it alone is what made the CLI tell a developer running a + * `package.json` script to `pnpm dlx @taskless/cli@latest auth login` — a + * command that reinstalls the CLI they already have pinned. What actually + * distinguishes the cases is where the binary lives: + * + * | Launcher | `argv[1]` lives under | + * | ------------- | ------------------------------------------- | + * | `npx` | the npm cache, `…/.npm/_npx//…` | + * | `pnpm dlx` | pnpm's dlx cache, `…/pnpm/dlx//…` | + * | `pnpm run` | the repository's own `node_modules/.bin` | + * | bare `node` | wherever the file happens to be | + * + * The last two are indistinguishable from each other, from a global install, + * and from a `yarn`/`bun` launch. That is why detection is allowed to answer + * "unknown" rather than falling back to a guess. */ -export function getCliPrefix(): string { - const ua = process.env.npm_config_user_agent ?? ""; - if (ua.startsWith("pnpm/")) return "pnpm dlx @taskless/cli@latest"; - if (ua.startsWith("yarn/")) { - const major = Number.parseInt(ua.slice("yarn/".length), 10); - if (Number.isFinite(major) && major >= 2) { - return "yarn dlx @taskless/cli@latest"; - } - return "npx @taskless/cli@latest"; + +/** A launcher this module is prepared to claim it recognized. */ +export type Launcher = "npx" | "pnpm-dlx"; + +/** + * Everything detection is allowed to look at. Injected rather than read, so + * every launcher case is a table row instead of a spawned process — the same + * reason `resolveBuildTarget` takes a `BuildEnvironment`. + */ +export interface LauncherContext { + env: Record; + argv: readonly string[]; +} + +/** This process's own context. The one place `process` is read. */ +export function processLauncherContext(): LauncherContext { + return { env: process.env, argv: process.argv }; +} + +/** Path segments of `argv[1]`, split on either separator so Windows works. */ +function launchPathSegments(context: LauncherContext): string[] { + return (context.argv[1] ?? "").split(/[/\\]/); +} + +/** + * Which launcher started this process, or `undefined` when nothing says. + * + * `undefined` is a real answer, not a failure. Every caller has a better + * fallback of its own than a launcher this function would have to invent: the + * recipe renderer emits an agent-fill marker, and the error messages print + * `npx`, which at least resolves for everyone. + */ +export function detectLauncher(context: LauncherContext): Launcher | undefined { + const segments = launchPathSegments(context); + + // npx: the cache directory is the strong signal; the env pair is what npx + // sets for the script it runs, and covers a launch whose path was resolved + // through a symlink. + if (segments.includes("_npx")) return "npx"; + if ( + context.env.npm_command === "exec" && + context.env.npm_lifecycle_event === "npx" + ) { + return "npx"; + } + + // pnpm dlx: BOTH signals are required. The user agent alone is set by every + // pnpm entry point, and a bare `dlx` path segment alone could be any + // directory someone happened to name that. + const userAgent = context.env.npm_config_user_agent ?? ""; + if (userAgent.startsWith("pnpm/") && segments.includes("dlx")) { + return "pnpm-dlx"; } - if (ua.startsWith("bun/")) return "bunx @taskless/cli@latest"; - return "npx @taskless/cli@latest"; + + return undefined; +} + +/** The command word for a launcher, as a reader would type it. */ +const LAUNCHER_COMMANDS: Record = { + npx: "npx", + "pnpm-dlx": "pnpm dlx", +}; + +/** The prefix a launcher-form build invocation carries. */ +const NPX_PREFIX = "npx "; + +/** + * The package specifier this build should be reached by, pinned to a version, + * or `undefined` when the build's invocation is a filesystem path. + * + * A `dev`/`self` build names `node `; no launcher applies to it and + * there is nothing to pin. A nightly's specifier already carries its exact + * version — deliberately, since a floating `@taskless/cli-nightly` resolves to + * whatever nightly is newest rather than the one whose instructions are being + * read. Only the released `@taskless/cli` needs `@latest` appended, because + * `npx` and `pnpm dlx` otherwise prefer whatever is already in the cache. + */ +function pinnedSpecifier(): string | undefined { + const invocation = buildInvocation(); + if (!invocation.startsWith(NPX_PREFIX)) return undefined; + const specifier = invocation.slice(NPX_PREFIX.length); + // Scoped names open with `@`, so a version pin is an `@` anywhere after it. + return specifier.includes("@", 1) ? specifier : `${specifier}@latest`; +} + +/** + * The full invocation for this process — launcher and pinned package — or + * `undefined` when the launcher could not be determined. + * + * This is what the `agent` command hands to the recipe renderer. Returning + * `undefined` rather than a default is the point: the renderer's marker asks + * the reading agent to supply the launcher it actually has, which beats naming + * one it may not. + */ +export function detectCliInvocation( + context: LauncherContext +): string | undefined { + const specifier = pinnedSpecifier(); + // A path-form build is authoritative and complete on its own. + if (specifier === undefined) return buildInvocation(); + const launcher = detectLauncher(context); + if (launcher === undefined) return undefined; + return `${LAUNCHER_COMMANDS[launcher]} ${specifier}`; +} + +/** + * The invocation to print in a message a human reads — an authentication + * prompt, an error remedy. + * + * Unlike {@link detectCliInvocation} this never returns `undefined`: a person + * staring at an error needs something runnable, and `npx` resolves on every + * machine. That display default is why the two functions are separate; a + * marker would be useless here and a guessed launcher is useless in a recipe. + */ +export function getCliPrefix(): string { + const detected = detectCliInvocation(processLauncherContext()); + if (detected !== undefined) return detected; + const specifier = pinnedSpecifier(); + return specifier === undefined + ? buildInvocation() + : `${LAUNCHER_COMMANDS.npx} ${specifier}`; } diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index 5a0eeb09..a1633d25 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -1,44 +1,177 @@ -import { describe, expect, it, afterEach } from "vitest"; -import { getCliPrefix } from "../src/util/package-manager"; +import { describe, expect, it } from "vitest"; -describe("getCliPrefix", () => { - const originalUa = process.env.npm_config_user_agent; - - afterEach(() => { - if (originalUa === undefined) { - delete process.env.npm_config_user_agent; - } else { - process.env.npm_config_user_agent = originalUa; - } +import { + detectCliInvocation, + detectLauncher, + getCliPrefix, + processLauncherContext, + type Launcher, + type LauncherContext, +} from "../src/util/package-manager"; + +/** + * These tests replace a suite that asserted `npm_config_user_agent` alone + * decided the launcher. That premise is the bug: `pnpm run`, `pnpm exec`, + * `pnpm dlx`, and every pnpm lifecycle script set the same `pnpm/…` agent, so + * the old suite passed while the CLI told developers running a `package.json` + * script to `pnpm dlx` a package they already had installed. + * + * Detection is pure over an injected context, so every launcher below is a + * table row rather than a spawned process and a mutated `process.env`. + */ + +const PNPM_AGENT = "pnpm/9.1.0 node/v22.0.0"; +const NPM_AGENT = "npm/10.0.0 node/v22.0.0"; + +function context( + argv1: string, + env: Record = {} +): LauncherContext { + return { env, argv: ["/usr/local/bin/node", argv1] }; +} + +describe("detectLauncher", () => { + const cases: Array<[string, LauncherContext, Launcher | undefined]> = [ + [ + "npx, by its cache path", + context("/Users/dev/.npm/_npx/a1b2c3/node_modules/.bin/taskless", { + npm_config_user_agent: NPM_AGENT, + }), + "npx", + ], + [ + "npx, by the environment it sets for the script it runs", + context("/somewhere/opaque/taskless", { + npm_command: "exec", + npm_lifecycle_event: "npx", + }), + "npx", + ], + [ + "pnpm dlx, by agent and cache path together", + context( + "/Users/dev/Library/Caches/pnpm/dlx/9f8e7d/node_modules/.bin/taskless", + { npm_config_user_agent: PNPM_AGENT } + ), + "pnpm-dlx", + ], + [ + // The regression this change exists to fix. `pnpm cli` in this very repo + // is exactly this shape. + "pnpm run is NOT pnpm dlx", + context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: PNPM_AGENT, + npm_command: "run-script", + npm_lifecycle_event: "cli", + PNPM_SCRIPT_SRC_DIR: "/repo", + }), + undefined, + ], + [ + "pnpm exec is NOT pnpm dlx", + context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: PNPM_AGENT, + }), + undefined, + ], + [ + "a dlx path without the pnpm agent is not enough", + context("/repo/dlx/node_modules/.bin/taskless"), + undefined, + ], + [ + "a node_modules/.bin shim under npm says nothing", + context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: NPM_AGENT, + }), + undefined, + ], + [ + "a bare node launch", + context("/repo/packages/cli/dist/index.js"), + undefined, + ], + ["an empty argv", { env: {}, argv: [] }, undefined], + [ + // Yarn and bun are deliberately not detected: the user agent is the only + // thing that distinguishes them, and the pnpm case is the proof that the + // user agent is not evidence of how anyone invoked anything. + "yarn is not detected", + context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: "yarn/4.1.0 node/v22.0.0", + }), + undefined, + ], + [ + "bun is not detected", + context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: "bun/1.0.0 node/v22.0.0", + }), + undefined, + ], + ]; + + it.each(cases)("%s", (_name, given, expected) => { + expect(detectLauncher(given)).toBe(expected); }); - it("returns pnpm dlx for pnpm", () => { - process.env.npm_config_user_agent = "pnpm/9.1.0 node/v22.0.0"; - expect(getCliPrefix()).toBe("pnpm dlx @taskless/cli@latest"); + it("reads Windows separators too", () => { + expect( + detectLauncher({ + env: {}, + argv: [ + String.raw`C:\node.exe`, + String.raw`C:\Users\dev\AppData\npm-cache\_npx\a1b2\node_modules\.bin\taskless`, + ], + }) + ).toBe("npx"); }); +}); - it("returns yarn dlx for Yarn Berry (v2+)", () => { - process.env.npm_config_user_agent = "yarn/4.1.0 node/v22.0.0"; - expect(getCliPrefix()).toBe("yarn dlx @taskless/cli@latest"); +describe("detectCliInvocation", () => { + // The test suite runs against a prod build, so the specifier is the released + // package pinned to @latest. + it("composes the launcher with the pinned package specifier", () => { + expect( + detectCliInvocation( + context("/Users/dev/.npm/_npx/a1b2c3/node_modules/.bin/taskless") + ) + ).toBe("npx @taskless/cli@latest"); + expect( + detectCliInvocation( + context("/Users/dev/Library/Caches/pnpm/dlx/9f8/node_modules/.bin/x", { + npm_config_user_agent: PNPM_AGENT, + }) + ) + ).toBe("pnpm dlx @taskless/cli@latest"); }); - it("falls back to npx for Yarn Classic (v1)", () => { - process.env.npm_config_user_agent = "yarn/1.22.19 node/v20.0.0"; - expect(getCliPrefix()).toBe("npx @taskless/cli@latest"); + it("is undefined when the launcher is unknown", () => { + // The renderer turns this into its agent-fill marker rather than naming a + // launcher the reader may not have. + expect(detectCliInvocation(context("/repo/dist/index.js"))).toBeUndefined(); }); +}); - it("returns bunx for bun", () => { - process.env.npm_config_user_agent = "bun/1.0.0 node/v22.0.0"; - expect(getCliPrefix()).toBe("bunx @taskless/cli@latest"); +describe("getCliPrefix", () => { + it("prints npx when detection is unknown, because a human needs something runnable", () => { + // Whatever launcher the test runner used, the printed command must resolve. + expect(getCliPrefix()).toMatch(/^(?:npx|pnpm dlx) @taskless\/cli@latest$/); }); - it("returns npx for npm", () => { - process.env.npm_config_user_agent = "npm/10.0.0 node/v22.0.0"; - expect(getCliPrefix()).toBe("npx @taskless/cli@latest"); + it("never suggests pnpm dlx from a pnpm script", () => { + // getCliPrefix reads the real process, so assert the underlying decision + // over the context a pnpm script actually presents. + const pnpmScript = context("/repo/node_modules/.bin/taskless", { + npm_config_user_agent: PNPM_AGENT, + npm_command: "run-script", + }); + expect(detectCliInvocation(pnpmScript)).toBeUndefined(); }); - it("returns npx when user agent is unset", () => { - delete process.env.npm_config_user_agent; - expect(getCliPrefix()).toBe("npx @taskless/cli@latest"); + it("reads argv and env from the live process", () => { + const live = processLauncherContext(); + expect(live.argv).toBe(process.argv); + expect(live.env).toBe(process.env); }); }); From 2b5e570f1d1058e973ba7e99daec05c5d6c31a83 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 12:55:03 -0700 Subject: [PATCH 04/12] fix(cli): require the real pnpm dlx cache shape, not a bare dlx segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectLauncher accepted any `dlx` path segment as the pnpm-dlx signal once the pnpm user agent was present. Every pnpm entry point sets that agent, so a repository containing a directory named `dlx` would have read an ordinary `pnpm run` as a dlx launch — a milder recurrence of the user-agent bug this module exists to fix. Match the documented cache shape instead: a pnpm store segment immediately before `dlx` (`pnpm` on macOS/Linux, `pnpm-cache` on Windows) and at least one segment after it. Adds table rows for the false-positive path, the trailing-`dlx` edge, and the Windows cache directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- packages/cli/src/util/package-manager.ts | 32 +++++++++++++++++++++-- packages/cli/test/package-manager.test.ts | 29 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 525f5456..4ecfa029 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -52,6 +52,34 @@ function launchPathSegments(context: LauncherContext): string[] { return (context.argv[1] ?? "").split(/[/\\]/); } +/** + * The directory pnpm keeps its dlx cache under, which is the store directory + * rather than a fixed name: `…/pnpm/dlx//…` on macOS and Linux, + * `…\pnpm-cache\dlx\\…` on Windows. Matching `pnpm` with an optional + * suffix covers both without accepting an unrelated parent directory. + */ +const PNPM_STORE_SEGMENT = /^pnpm(?:[-_][\w.-]+)?$/; + +/** + * Whether `argv[1]` runs out of pnpm's dlx cache, as opposed to merely passing + * through some directory a person named `dlx`. + * + * The cache shape is `/dlx//…`, so a real hit has a pnpm + * store segment immediately before `dlx` and at least one segment after it. + * Requiring only a bare `dlx` segment would misread an ordinary `pnpm run` in + * any repository that happens to contain a directory of that name — a milder + * recurrence of the user-agent bug this module exists to fix. + */ +function inPnpmDlxCache(segments: readonly string[]): boolean { + return segments.some( + (segment, index) => + segment === "dlx" && + index > 0 && + PNPM_STORE_SEGMENT.test(segments[index - 1] ?? "") && + index + 1 < segments.length + ); +} + /** * Which launcher started this process, or `undefined` when nothing says. * @@ -76,9 +104,9 @@ export function detectLauncher(context: LauncherContext): Launcher | undefined { // pnpm dlx: BOTH signals are required. The user agent alone is set by every // pnpm entry point, and a bare `dlx` path segment alone could be any - // directory someone happened to name that. + // directory someone happened to name that — hence the full cache shape. const userAgent = context.env.npm_config_user_agent ?? ""; - if (userAgent.startsWith("pnpm/") && segments.includes("dlx")) { + if (userAgent.startsWith("pnpm/") && inPnpmDlxCache(segments)) { return "pnpm-dlx"; } diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index a1633d25..158edb5a 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -79,6 +79,35 @@ describe("detectLauncher", () => { context("/repo/dlx/node_modules/.bin/taskless"), undefined, ], + [ + // The other half of that pair, and the one that actually bites: every + // pnpm entry point sets the agent, so a repository that merely contains + // a directory named `dlx` must not read as a dlx launch. + "a dlx directory outside the pnpm cache is not pnpm dlx", + context("/repo/dlx/node_modules/.bin/taskless", { + npm_config_user_agent: PNPM_AGENT, + }), + undefined, + ], + [ + "a trailing dlx segment has no cache hash after it", + context("/Users/dev/Library/Caches/pnpm/dlx", { + npm_config_user_agent: PNPM_AGENT, + }), + undefined, + ], + [ + // Windows keeps the dlx cache under `pnpm-cache`, not `pnpm`. + "pnpm dlx from the Windows cache directory", + { + env: { npm_config_user_agent: PNPM_AGENT }, + argv: [ + String.raw`C:\node.exe`, + String.raw`C:\Users\dev\AppData\Local\pnpm-cache\dlx\9f8e7d\node_modules\.bin\taskless`, + ], + }, + "pnpm-dlx", + ], [ "a node_modules/.bin shim under npm says nothing", context("/repo/node_modules/.bin/taskless", { From c4184189f75704f17bd3e0daa0a0700ff85c1ba2 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 11:17:22 -0700 Subject: [PATCH 05/12] refactor(cli): name the CLI by invocation in nine recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace every `taskless ` and `npx @taskless/cli ` in the nine heaviest recipes with `%(TASKLESS_CLI)s `, so the launcher, package name, and version pin are stated once by the renderer instead of 93 times in prose. The bare form was never rewritten for a non-prod build. applyCliInvocation only ever replaced the `npx @taskless/cli` spelling, so an agent reading a nightly's `route` recipe was told to run `taskless agent create-sg-rule` — resolving to whatever @taskless/cli happened to be on the machine rather than the nightly whose behavior it was there to exercise. Verified against a `self` build: every invocation in the rendered recipe now names `node packages/cli/dist-self/index.js`, including the ones that used to say `taskless`. ci.txt keeps %(PACKAGE_MANAGER_DLX)s. It answers a different question — which launcher the repository being wired up should use in its own CI — and its prose now points at %(TASKLESS_CLI)s for the package rather than spelling out four hardcoded `@taskless/cli` variants, three of which no build target rewrites. The orientation-banner assertion moves to the rendered marker, which is what a prod build spawned as `node dist/…` emits when nothing knows the launcher. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- openspec/changes/full-cli-invocation/tasks.md | 6 +-- packages/cli/src/agent/ci.txt | 35 ++++++++-------- packages/cli/src/agent/create-legacy-rule.txt | 14 +++---- packages/cli/src/agent/create-remote-rule.txt | 24 +++++------ .../cli/src/agent/create-runtime-rule.txt | 22 +++++----- packages/cli/src/agent/create-sg-rule.txt | 22 +++++----- packages/cli/src/agent/create-vale-rule.txt | 16 ++++---- packages/cli/src/agent/improve-rule.txt | 24 +++++------ packages/cli/src/agent/onboard.txt | 40 +++++++++---------- packages/cli/src/agent/route.txt | 20 +++++----- packages/cli/test/agent-extensions.test.ts | 5 ++- 11 files changed, 117 insertions(+), 111 deletions(-) diff --git a/openspec/changes/full-cli-invocation/tasks.md b/openspec/changes/full-cli-invocation/tasks.md index f71b55ee..9acff20c 100644 --- a/openspec/changes/full-cli-invocation/tasks.md +++ b/openspec/changes/full-cli-invocation/tasks.md @@ -22,9 +22,9 @@ Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branc ## 3. PR 3 — recipe normalization, part A -- [ ] 3.1 Replace every bare `` `taskless ` `` and every `npx @taskless/cli ` with `%(TASKLESS_CLI)s ` across the heaviest recipes. Leave prose mentions of the product, `taskless.config`, and `.taskless/` alone -- [ ] 3.2 Leave `ci.txt`'s `%(PACKAGE_MANAGER_DLX)s` occurrences in place — they answer what the _consuming repo's_ CI should type, which is a different question from how this process was launched. Its one prose reference to `npx @taskless/cli` becomes `%(TASKLESS_CLI)s` -- [ ] 3.3 Note in the PR body that `onboard.txt` conflicts with PR #142, which lands first +- [x] 3.1 Replace every bare `` `taskless ` `` and every `npx @taskless/cli ` with `%(TASKLESS_CLI)s ` across the heaviest recipes. Leave prose mentions of the product, `taskless.config`, and `.taskless/` alone +- [x] 3.2 Leave `ci.txt`'s `%(PACKAGE_MANAGER_DLX)s` occurrences in place — they answer what the _consuming repo's_ CI should type, which is a different question from how this process was launched. Its one prose reference to `npx @taskless/cli` becomes `%(TASKLESS_CLI)s` +- [x] 3.3 Note in the PR body that `onboard.txt` conflicts with PR #142, which lands first ## 4. PR 4 — recipe normalization, part B diff --git a/packages/cli/src/agent/ci.txt b/packages/cli/src/agent/ci.txt index 8fba40d5..4dc7041d 100644 --- a/packages/cli/src/agent/ci.txt +++ b/packages/cli/src/agent/ci.txt @@ -1,7 +1,7 @@ # Topic: ci (CLI v%(CLI_VERSION)s / topic v1) ## Goal -Wire `taskless check` into the user's existing CI so rules run +Wire `%(TASKLESS_CLI)s check` into the user's existing CI so rules run automatically on pushes and pull requests. Integrate with what they already have — never replace or edit their main pipeline. @@ -12,9 +12,9 @@ you recognize one not on the list, apply the same patterns. ## Preconditions - `.taskless/` directory exists and contains at least one rule. (If no rules exist, instruct the user to fetch - `taskless agent route` first — wiring CI with zero rules + `%(TASKLESS_CLI)s agent route` first — wiring CI with zero rules produces an always-green check that gives false confidence.) -- A local `taskless check` succeeds (or fails with real findings the +- A local `%(TASKLESS_CLI)s check` succeeds (or fails with real findings the user is OK with seeing in CI's first run). - No auth required for CI — `check` is unauthenticated by default. (Optionally tokenized as a server-enforced backstop; see step 7.) @@ -42,9 +42,9 @@ which CI they use. If multiple match, ask which should run Taskless. ### 2. Agree on the scan pattern -- **Full scan** — `taskless check`. Scans everything. Best for runs +- **Full scan** — `%(TASKLESS_CLI)s check`. Scans everything. Best for runs on the main/default branch. -- **Diff scan** — `taskless check $(git diff --name-only ...)`. +- **Diff scan** — `%(TASKLESS_CLI)s check $(git diff --name-only ...)`. Faster for PR builds. Per-CI diff target var: | CI | Target branch variable | @@ -56,7 +56,7 @@ which CI they use. If multiple match, ask which should run Taskless. | Azure Pipelines | `System.PullRequest.TargetBranch` | | Bitbucket Pipelines | `BITBUCKET_PR_DESTINATION_BRANCH` | - `taskless check` silently filters paths that don't exist, so raw + `%(TASKLESS_CLI)s check` silently filters paths that don't exist, so raw `git diff --name-only` output can pipe in directly. **Recommended default:** diff scan on PRs, full scan on pushes to @@ -64,9 +64,9 @@ main. ### 3. Verify locally first -Run `npx @taskless/cli check`: +Run `%(TASKLESS_CLI)s check`: - Clean pass → proceed. -- "No rules configured" → stop. Fetch `taskless agent route`. +- "No rules configured" → stop. Fetch `%(TASKLESS_CLI)s agent route`. - Findings → tell the user CI will fail; ask whether to fix, suppress, or proceed knowing the first CI run will be red. @@ -96,9 +96,12 @@ Canonical paths: The reference template — translate the same shape (checkout with full history, set up Node, conditional check) for other CIs. -Substitute `%(PACKAGE_MANAGER_DLX)s` with `npx @taskless/cli`, -`pnpm dlx @taskless/cli`, `yarn dlx @taskless/cli`, or -`bunx @taskless/cli` based on `pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`. +Substitute `%(PACKAGE_MANAGER_DLX)s` with the CI runner's own launcher — +`npx`, `pnpm dlx`, `yarn dlx`, or `bunx` based on +`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb` — followed by the same package +`%(TASKLESS_CLI)s` names. That is a question about the repository you are +wiring up, not about how this recipe was fetched, which is why it stays a +separate placeholder. ```yaml name: Taskless @@ -158,7 +161,7 @@ different structure for CircleCI. The six steps stay the same. ### 7. Authentication in CI (optional backstop) -`taskless check` does NOT require authentication. The generated CI +`%(TASKLESS_CLI)s check` does NOT require authentication. The generated CI config works out of the box with no secrets and scans all local rules. Static ast-grep rules always run in CI with no secrets. **Runtime @@ -191,7 +194,7 @@ the user explicitly asks to run authenticated commands (e.g. - **pnpm**: `pnpm dlx` works but has slow cold starts. If the user's pipeline already sets up pnpm, suggest adding `@taskless/cli` as a - dev dep and calling it via `pnpm taskless check`. + dev dep and calling it via `pnpm %(TASKLESS_CLI)s check`. - **Yarn v1 (classic)**: doesn't support `yarn dlx`. Use `npx`. - **Bun**: `bunx` works. @@ -207,7 +210,7 @@ Show: ## Errors -- **No rules** → fetch `taskless agent route`. Don't write CI +- **No rules** → fetch `%(TASKLESS_CLI)s agent route`. Don't write CI config. - **Unrecognized CI** → produce a generic `.taskless/ci/check.sh` script implementing the six universal steps. Be upfront it's a @@ -217,5 +220,5 @@ Show: ## See Also -- `taskless agent check` — the command being wired into CI -- `taskless agent route` — required if no rules exist yet +- `%(TASKLESS_CLI)s agent check` — the command being wired into CI +- `%(TASKLESS_CLI)s agent route` — required if no rules exist yet diff --git a/packages/cli/src/agent/create-legacy-rule.txt b/packages/cli/src/agent/create-legacy-rule.txt index 6920cc8c..3a642e64 100644 --- a/packages/cli/src/agent/create-legacy-rule.txt +++ b/packages/cli/src/agent/create-legacy-rule.txt @@ -4,7 +4,7 @@ This is `create-legacy-rule`. It helps you write a rule for a linter the repository already runs — ESLint, Ruff, RuboCop, Stylelint — in that tool's own dialect, so that tool enforces it. -If that is not the kind of check you need, re-run `taskless agent route` +If that is not the kind of check you need, re-run `%(TASKLESS_CLI)s agent route` and follow its decision rather than adapting this recipe. ## Goal @@ -15,13 +15,13 @@ rules — you source the knowledge from the repo first and the web second, then write the rule where that tool expects it. ## Preconditions -- The repo has a detected linter (confirm via `taskless detect --json`). +- The repo has a detected linter (confirm via `%(TASKLESS_CLI)s detect --json`). - The agent can read/write files and fetch web pages. - No auth required. ## Steps -1. **Confirm the target tool.** Use `taskless detect --json` to identify +1. **Confirm the target tool.** Use `%(TASKLESS_CLI)s detect --json` to identify which linter is configured. If more than one could host this rule, ask the user which tool should own it. @@ -47,7 +47,7 @@ then write the rule where that tool expects it. 5. **Report, and be explicit about who runs it.** Show the file(s) you changed. Make clear that the user's OWN toolchain runs this rule — - `taskless check` does NOT execute external linters. Tell the user how + `%(TASKLESS_CLI)s check` does NOT execute external linters. Tell the user how to run their linter to see it fire (e.g. their existing lint script). ## Important Notes @@ -59,6 +59,6 @@ then write the rule where that tool expects it. ## See Also -- `taskless agent route` — re-decide the destination if this no longer fits -- `taskless agent create-sg-rule` — author a local ast-grep rule instead -- `taskless agent create-remote-rule` — generate via the service (login) +- `%(TASKLESS_CLI)s agent route` — re-decide the destination if this no longer fits +- `%(TASKLESS_CLI)s agent create-sg-rule` — author a local ast-grep rule instead +- `%(TASKLESS_CLI)s agent create-remote-rule` — generate via the service (login) diff --git a/packages/cli/src/agent/create-remote-rule.txt b/packages/cli/src/agent/create-remote-rule.txt index c4ec148e..a1178583 100644 --- a/packages/cli/src/agent/create-remote-rule.txt +++ b/packages/cli/src/agent/create-remote-rule.txt @@ -4,7 +4,7 @@ This is `create-remote-rule`. It helps you have the Taskless service write a rule, when the rule is beyond what you can express on-device or the user has chosen to spend a generation on it. -If that is not the kind of check you need, re-run `taskless agent route` +If that is not the kind of check you need, re-run `%(TASKLESS_CLI)s agent route` and follow its decision rather than adapting this recipe. ## Goal @@ -43,16 +43,16 @@ Two ways to legitimately be here: 1. **Confirm auth.** Run: ``` - npx @taskless/cli info --json + %(TASKLESS_CLI)s info --json ``` - Check `loggedIn`. If false, fetch `taskless agent auth` and follow the + Check `loggedIn`. If false, fetch `%(TASKLESS_CLI)s agent auth` and follow the login recipe before continuing. Do not build a request you cannot submit. 2. **Check for a rule that already covers this.** Scan `.taskless/rules/sg/` and read each rule's `message`, `note`, and `rule` fields. If one overlaps, show the user and ask whether they - would rather iterate on it — `taskless agent improve-rule` refines an + would rather iterate on it — `%(TASKLESS_CLI)s agent improve-rule` refines an existing rule and is usually the better answer than a second rule that half-overlaps the first. @@ -85,7 +85,7 @@ Two ways to legitimately be here: 7. **Submit.** Run: ``` - npx @taskless/cli rule create --from .taskless/.tmp-rule-request.json --json + %(TASKLESS_CLI)s rule create --from .taskless/.tmp-rule-request.json --json ``` This may take 30–60 seconds while the service generates the rule. @@ -98,7 +98,7 @@ Two ways to legitimately be here: metadata to `.taskless/rule-metadata/.yml`. These are the same paths and the same shape a locally authored rule uses, so `check`, `improve-rule`, `verify`, and `test` treat them identically. Show the - user the paths and suggest `taskless agent check`. + user the paths and suggest `%(TASKLESS_CLI)s agent check`. ## Input schema @@ -126,7 +126,7 @@ With `--json`, failures emit `{ ok: false, code, message }`: | code | meaning | fix | |--------------------------|------------------------------------|----------------------------------------------| -| `AUTH_REQUIRED` | not logged in | fetch `taskless agent auth` | +| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | | `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | | `INVALID_INPUT` | `--from` JSON failed validation | re-read the input schema, fix, retry | | `NETWORK_ERROR` | submit/poll failed | report and suggest retry | @@ -135,8 +135,8 @@ With `--json`, failures emit `{ ok: false, code, message }`: ## See Also -- `taskless agent route` — the routing decision that leads here -- `taskless agent auth` — log in before generating -- `taskless agent create-sg-rule` — author a rule locally instead -- `taskless agent improve-rule` — iterate on a rule that already exists -- `taskless agent check` — validate the generated rule +- `%(TASKLESS_CLI)s agent route` — the routing decision that leads here +- `%(TASKLESS_CLI)s agent auth` — log in before generating +- `%(TASKLESS_CLI)s agent create-sg-rule` — author a rule locally instead +- `%(TASKLESS_CLI)s agent improve-rule` — iterate on a rule that already exists +- `%(TASKLESS_CLI)s agent check` — validate the generated rule diff --git a/packages/cli/src/agent/create-runtime-rule.txt b/packages/cli/src/agent/create-runtime-rule.txt index 2f898bb6..490c4735 100644 --- a/packages/cli/src/agent/create-runtime-rule.txt +++ b/packages/cli/src/agent/create-runtime-rule.txt @@ -5,7 +5,7 @@ This is `create-runtime-rule`. It helps you write a runtime rule: a check that runs your own code, because answering it needs more than one file — the repository graph, git metadata, build output, a resolved config chain. -If that is not the kind of check you need, re-run `taskless agent route` +If that is not the kind of check you need, re-run `%(TASKLESS_CLI)s agent route` and follow its decision rather than adapting this recipe. You are reading this recipe rather than `create-remote-rule` because the @@ -18,9 +18,9 @@ tiers are not, and what the user has to do before one can run. ## Preconditions - `.taskless/` directory exists. -- The user is **not** logged in. If `taskless info --json` reports +- The user is **not** logged in. If `%(TASKLESS_CLI)s info --json` reports `loggedIn: true`, you are in the wrong recipe — re-run - `taskless agent route`. + `%(TASKLESS_CLI)s agent route`. ## What a runtime rule is @@ -46,7 +46,7 @@ That is also exactly why it is gated. **Because it executes code, not because of what it can express.** `sg` and `vale` rules are inert data. Whatever is in them, the worst a -malicious rule achieves is a wrong finding — `taskless check` runs them +malicious rule achieves is a wrong finding — `%(TASKLESS_CLI)s check` runs them with no login, no network, and nothing to verify, because there is nothing to verify. @@ -71,7 +71,7 @@ runtime rule is more or less capable, and it is not a quality tier — ## What happens today, logged out -Nothing breaks. `taskless check` still runs every static rule; each +Nothing breaks. `%(TASKLESS_CLI)s check` still runs every static rule; each runtime rule it finds is listed as skipped with the reason `not authenticated — runtime rules were not verified and did not run`. @@ -89,7 +89,7 @@ that plainly rather than letting them discover it from a silent check. recipe deliberately does not restate it: ``` - npx @taskless/cli agent auth + %(TASKLESS_CLI)s agent auth ``` Follow that recipe with the user. If they do not want an account, @@ -99,8 +99,8 @@ that plainly rather than letting them discover it from a silent check. not a fallback you take on their behalf. 3. **Once they are logged in, re-route.** Run - `npx @taskless/cli info --json` to confirm `loggedIn: true`, then - re-run `taskless agent route` with the original request. The + `%(TASKLESS_CLI)s info --json` to confirm `loggedIn: true`, then + re-run `%(TASKLESS_CLI)s agent route` with the original request. The destination changes now that the gate is open. ## Important Notes @@ -116,6 +116,6 @@ that plainly rather than letting them discover it from a silent check. ## See Also -- `taskless agent auth` — log in, log out, check status -- `taskless agent route` — re-decide once the login state changes -- `taskless agent check` — see which rules ran and which were skipped +- `%(TASKLESS_CLI)s agent auth` — log in, log out, check status +- `%(TASKLESS_CLI)s agent route` — re-decide once the login state changes +- `%(TASKLESS_CLI)s agent check` — see which rules ran and which were skipped diff --git a/packages/cli/src/agent/create-sg-rule.txt b/packages/cli/src/agent/create-sg-rule.txt index 973f3299..2ae50c62 100644 --- a/packages/cli/src/agent/create-sg-rule.txt +++ b/packages/cli/src/agent/create-sg-rule.txt @@ -3,7 +3,7 @@ ## You are here This is `create-sg-rule`. It helps you write an ast-grep rule: a check over the structure of a single source file, authored on this machine. -If that is not the kind of check you need, re-run `taskless agent route` +If that is not the kind of check you need, re-run `%(TASKLESS_CLI)s agent route` and follow its decision rather than adapting this recipe. ## Goal @@ -59,7 +59,7 @@ whole rule. 3. **Check for an existing rule that already covers this.** Scan `.taskless/rules/sg/` and read each rule's `message`, `note`, and `rule` fields. If one overlaps, show the user and ask whether they - would rather improve it — `taskless agent improve-rule --anonymous` + would rather improve it — `%(TASKLESS_CLI)s agent improve-rule --anonymous` iterates a rule locally. 4. **Author the rule in the canonical shape.** Write the rule to @@ -85,8 +85,8 @@ whole rule. 6. **Run the verify feedback loop.** Both commands take the rule's directory as their argument: ``` - npx @taskless/cli verify .taskless/rules/sg/ --json - npx @taskless/cli test .taskless/rules/sg/ --json + %(TASKLESS_CLI)s verify .taskless/rules/sg/ --json + %(TASKLESS_CLI)s test .taskless/rules/sg/ --json ``` `verify` asks whether the rule is well-formed: the YAML matches the ast-grep schema and every Taskless-required field is present. It @@ -117,7 +117,7 @@ whole rule. 7. **On success, report.** Show the rule directory and what is in it, plus a one-line summary of what the rule detects. Suggest - `taskless agent check` to validate against the broader codebase. + `%(TASKLESS_CLI)s agent check` to validate against the broader codebase. 8. **On failure, escalate — with confirmation.** If after the feedback loop the rule still cannot capture the user's cases: @@ -127,7 +127,7 @@ whole rule. - Tell the user the local rule could not capture the cases, and that generating via the Taskless service uses a generation and requires login. - - Only after the user confirms, fetch `taskless agent + - Only after the user confirms, fetch `%(TASKLESS_CLI)s agent create-remote-rule` and follow it. Do not call the service silently. ## Important Notes @@ -140,8 +140,8 @@ whole rule. ## See Also -- `taskless agent route` — re-decide the destination -- `taskless agent verify-rule` — the `verify` and `test` commands step 6 calls -- `taskless agent improve-rule` — iterate on a rule that already exists -- `taskless agent create-remote-rule` — generate via the service (login) -- `taskless agent check` — validate the new rule against the codebase +- `%(TASKLESS_CLI)s agent route` — re-decide the destination +- `%(TASKLESS_CLI)s agent verify-rule` — the `verify` and `test` commands step 6 calls +- `%(TASKLESS_CLI)s agent improve-rule` — iterate on a rule that already exists +- `%(TASKLESS_CLI)s agent create-remote-rule` — generate via the service (login) +- `%(TASKLESS_CLI)s agent check` — validate the new rule against the codebase diff --git a/packages/cli/src/agent/create-vale-rule.txt b/packages/cli/src/agent/create-vale-rule.txt index 4e204b0e..2e731071 100644 --- a/packages/cli/src/agent/create-vale-rule.txt +++ b/packages/cli/src/agent/create-vale-rule.txt @@ -3,7 +3,7 @@ ## You are here This is `create-vale-rule`. It helps you write a Vale rule: a check over the words of a document — prose, markup, and the prose parts of code. -If that is not the kind of check you need, re-run `taskless agent route` +If that is not the kind of check you need, re-run `%(TASKLESS_CLI)s agent route` and follow its decision rather than adapting this recipe. ## Goal @@ -336,8 +336,8 @@ it. directory as their argument, both run from the project root: ``` - npx @taskless/cli verify .taskless/rules/vale/ --json - npx @taskless/cli test .taskless/rules/vale/ --json + %(TASKLESS_CLI)s verify .taskless/rules/vale/ --json + %(TASKLESS_CLI)s test .taskless/rules/vale/ --json ``` `verify` asks whether the rule is well-formed: the style file parses, @@ -368,7 +368,7 @@ it. line numbers, run `check` against a bucket instead: ``` - npx @taskless/cli check .taskless/rules/vale//.tests/fail --json + %(TASKLESS_CLI)s check .taskless/rules/vale//.tests/fail --json ``` Read `results` there. Ignore `success` and the exit code: `success` @@ -392,7 +392,7 @@ it. 7. **Report.** Show the rule directory you created and what is in it, a one-line summary of what the rule flags, and the glob it is scoped to. The scope is a decision the user should see rather than one - buried in a config. Note that a whole-project `taskless check` skips + buried in a config. Note that a whole-project `%(TASKLESS_CLI)s check` skips your fixtures: `.taskless/` is excluded from the project walk, by design. `test` is what exercises them. @@ -602,6 +602,6 @@ definition when the acronym is missing". ## See Also -- `taskless agent route` — re-decide the destination -- `taskless agent check` — run every engine over the repo -- `taskless agent create-sg-rule` — author a rule over code structure +- `%(TASKLESS_CLI)s agent route` — re-decide the destination +- `%(TASKLESS_CLI)s agent check` — run every engine over the repo +- `%(TASKLESS_CLI)s agent create-sg-rule` — author a rule over code structure diff --git a/packages/cli/src/agent/improve-rule.txt b/packages/cli/src/agent/improve-rule.txt index 66bd5526..320f867a 100644 --- a/packages/cli/src/agent/improve-rule.txt +++ b/packages/cli/src/agent/improve-rule.txt @@ -8,7 +8,7 @@ is to gather the right ruleId + guidance + supporting references and to report the result. If the user wants the local-only flow (no API call), fetch -`taskless agent improve-rule --anonymous` instead. +`%(TASKLESS_CLI)s agent improve-rule --anonymous` instead. ## Preconditions - User is logged in. @@ -21,8 +21,8 @@ If the user wants the local-only flow (no API call), fetch ## Steps -1. **Confirm auth.** Run `npx @taskless/cli info --json` and check - `loggedIn`. If false, fetch `taskless agent auth`. +1. **Confirm auth.** Run `%(TASKLESS_CLI)s info --json` and check + `loggedIn`. If false, fetch `%(TASKLESS_CLI)s agent auth`. 2. **Identify the rule to improve.** If the user named one, use it. Otherwise, list rules in `.taskless/rules/sg/` and ask which one. @@ -30,11 +30,11 @@ If the user wants the local-only flow (no API call), fetch 3. **Fetch the rule's metadata.** Run: ``` - npx @taskless/cli rule meta --json + %(TASKLESS_CLI)s rule meta --json ``` This returns the `ticketId` needed for the iterate request. If the metadata is missing, the rule cannot be iterated via API — fetch - `taskless agent improve-rule --anonymous` instead. + `%(TASKLESS_CLI)s agent improve-rule --anonymous` instead. 4. **Gather improvement guidance.** Ask the user what should change: - Are there false positives we need to exclude? @@ -60,7 +60,7 @@ If the user wants the local-only flow (no API call), fetch 8. **Invoke the CLI.** Run: ``` - npx @taskless/cli rule improve --from .taskless/.tmp-improve-request.json --json + %(TASKLESS_CLI)s rule improve --from .taskless/.tmp-improve-request.json --json ``` This may take 30–60 seconds while the API generates the update. @@ -69,7 +69,7 @@ If the user wants the local-only flow (no API call), fetch 10. **Report results.** The CLI overwrites the rule file (and its test file) with the updated version. Show the file paths and a - summary of what changed. Suggest fetching `taskless agent check` + summary of what changed. Suggest fetching `%(TASKLESS_CLI)s agent check` to validate. ## Input schema @@ -81,7 +81,7 @@ The `--from` JSON file conforms to: ``` `ruleId` is the original rule's ticket ID (returned by -`taskless rule meta --json`), not the YAML file name. +`%(TASKLESS_CLI)s rule meta --json`), not the YAML file name. ## Errors @@ -89,7 +89,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: | code | meaning | fix | |--------------------------|----------------------------------------|----------------------------------------------| -| `AUTH_REQUIRED` | not logged in | fetch `taskless agent auth` | +| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | | `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | | `INVALID_INPUT` | `--from` JSON failed validation | re-read input schema, fix, retry | | `RULE_NOT_FOUND` | metadata missing for the given rule | use anonymous variant or recreate via create | @@ -99,6 +99,6 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: ## See Also -- `taskless agent improve-rule --anonymous` — local-only flow -- `taskless agent route` — make a new rule from scratch -- `taskless agent check` — validate the updated rule +- `%(TASKLESS_CLI)s agent improve-rule --anonymous` — local-only flow +- `%(TASKLESS_CLI)s agent route` — make a new rule from scratch +- `%(TASKLESS_CLI)s agent check` — validate the updated rule diff --git a/packages/cli/src/agent/onboard.txt b/packages/cli/src/agent/onboard.txt index 0cd795eb..00fb756a 100644 --- a/packages/cli/src/agent/onboard.txt +++ b/packages/cli/src/agent/onboard.txt @@ -11,22 +11,22 @@ rules as a bullet list the user can choose to materialize via the ## Preconditions - `.taskless/` directory exists (Taskless is installed). The - `taskless onboard` subcommand bootstraps it on first run, so this + `%(TASKLESS_CLI)s onboard` subcommand bootstraps it on first run, so this is automatically satisfied. - A working repository the agent can read. - No auth required to surface candidates. (Materializing a rule via - `rule create` may require auth — fetch `taskless agent auth` if + `rule create` may require auth — fetch `%(TASKLESS_CLI)s agent auth` if needed at that point.) -- The criterion for where a rule belongs lives in `taskless agent - route` and is not restated here. This recipe reads it; it does not - duplicate it. +- The criterion for where a rule belongs lives in + `%(TASKLESS_CLI)s agent route` and is not restated here. This recipe + reads it; it does not duplicate it. ## Steps 1. **Read the manifest first.** Run - `npx @taskless/cli info --json` and check whether + `%(TASKLESS_CLI)s info --json` and check whether `.taskless/taskless.json` reports `install.onboarded: true`. The - `taskless onboard` subcommand has already gated on this for you, + `%(TASKLESS_CLI)s onboard` subcommand has already gated on this for you, but if the user is invoking the recipe directly via the skill, confirm with the user before running a long discovery pass. @@ -35,8 +35,8 @@ rules as a bullet list the user can choose to materialize via the you cannot tell which is which until you know two things: ``` - npx @taskless/cli agent route - npx @taskless/cli detect --json + %(TASKLESS_CLI)s agent route + %(TASKLESS_CLI)s detect --json ``` Read `route` for the destination criterion — which rules are @@ -123,10 +123,10 @@ rules as a bullet list the user can choose to materialize via the 7. **Offer materialization per bullet.** For each bullet, ask whether the user wants to turn it into a real Taskless rule. On - yes, follow the `taskless agent route` recipe you already fetched - when you learned the routing surface — the user's accepted bullet - becomes the rule description input. Re-fetch it only if it has - fallen out of context. + yes, follow the `%(TASKLESS_CLI)s agent route` recipe you already + fetched when you learned the routing surface — the user's accepted + bullet becomes the rule description input. Re-fetch it only if it + has fallen out of context. 8. **Ask before marking onboarding complete.** When the user signals they're done (they've materialized everything they want, or @@ -136,7 +136,7 @@ rules as a bullet list the user can choose to materialize via the on explicit yes — run: ``` - npx @taskless/cli onboard --mark-complete + %(TASKLESS_CLI)s onboard --mark-complete ``` Do NOT mark onboarding complete on your own initiative. Do NOT @@ -151,9 +151,9 @@ rules as a bullet list the user can choose to materialize via the ## See Also -- `taskless agent route` — the destination criterion; read it before - proposing candidates, and follow it to materialize an accepted one -- `taskless agent detect` — what this repository already lints and - authors, which bounds what a candidate can be -- `taskless agent check` — validate newly created rules against the codebase -- `taskless agent info` — inspect the current `.taskless/taskless.json` state +- `%(TASKLESS_CLI)s agent route` — the destination criterion; read it + before proposing candidates, and follow it to materialize an accepted one +- `%(TASKLESS_CLI)s agent detect` — what this repository already lints + and authors, which bounds what a candidate can be +- `%(TASKLESS_CLI)s agent check` — validate newly created rules against the codebase +- `%(TASKLESS_CLI)s agent info` — inspect the current `.taskless/taskless.json` state diff --git a/packages/cli/src/agent/route.txt b/packages/cli/src/agent/route.txt index 559fa1f7..1e0122ed 100644 --- a/packages/cli/src/agent/route.txt +++ b/packages/cli/src/agent/route.txt @@ -19,7 +19,7 @@ answered together. 1. **Read the repository.** Run: ``` - npx @taskless/cli detect --json + %(TASKLESS_CLI)s detect --json ``` This returns the configured linters, languages, and the repo's own rule styles. It is deterministic and offline — use it as ground truth @@ -38,7 +38,7 @@ answered together. 2. **Read the login state.** Run: ``` - npx @taskless/cli info --json + %(TASKLESS_CLI)s info --json ``` and note `loggedIn`. Do this now, not later: it changes which destinations exist, so classifying first means classifying against a @@ -122,7 +122,7 @@ answered together. 7. **Name the command.** Finish by telling the user, or running, the exact fetch for the destination you chose: ``` - npx @taskless/cli agent create-vale-rule + %(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. @@ -148,7 +148,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 agent create-remote-rule`. + `%(TASKLESS_CLI)s agent create-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 @@ -178,9 +178,9 @@ will work. ## See Also -- `taskless agent create-legacy-rule` — author in a linter the repo already uses -- `taskless agent create-sg-rule` — author a local ast-grep rule (no login) -- `taskless agent create-vale-rule` — author a local Vale rule (no login) -- `taskless agent create-runtime-rule` — the runtime tier, logged out -- `taskless agent create-remote-rule` — generate via the service (login) -- `taskless agent check` — run every engine over the repo +- `%(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 diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index 40867dcb..086f1122 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -124,7 +124,10 @@ describe("taskless agent ", () => { const result = await runCli(["agent", topic, "-d", cwd]); expect(result.stdout).toContain("## You are here"); expect(result.stdout).toContain(`This is \`${topic}\`.`); - expect(result.stdout).toContain("`taskless agent route`"); + // Recipes name the CLI through `%(TASKLESS_CLI)s`, which renders as the + // agent-fill marker here: this is a prod build spawned as `node dist/…`, + // so nothing knows which launcher the reader has. + expect(result.stdout).toContain("` agent route`"); }); it.each(["existing", "static", "remote", "engine-selection", "rule-create"])( From 1ee565e18c4345a0569db3cacf5c8ba4d2f828a1 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 12:55:22 -0700 Subject: [PATCH 06/12] fix(cli): keep ci.txt's two non-invocation commands out of %(TASKLESS_CLI)s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `%(TASKLESS_CLI)s` renders a complete invocation (launcher + package specifier), so two sites in `ci.txt` were wrong after the normalization pass: - Step 8's pnpm caveat became `pnpm %(TASKLESS_CLI)s check`, which double-prefixes into `pnpm npx @taskless/cli check`. That `taskless` is the binary pnpm resolves from the wired repo's own `node_modules/.bin` when `@taskless/cli` is a dev dependency — not this CLI's invocation. Restored to `pnpm taskless check` with a note saying why. - Step 5's `%(PACKAGE_MANAGER_DLX)s` paragraph described the placeholder as a bare launcher to be followed by `%(TASKLESS_CLI)s`, but the template below uses it alone (`%(PACKAGE_MANAGER_DLX)s check`), so it must expand to a full invocation. Reworded to name the four complete forms and to say the two placeholders are never combined. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- packages/cli/src/agent/ci.txt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/agent/ci.txt b/packages/cli/src/agent/ci.txt index 4dc7041d..7ad7d784 100644 --- a/packages/cli/src/agent/ci.txt +++ b/packages/cli/src/agent/ci.txt @@ -96,12 +96,14 @@ Canonical paths: The reference template — translate the same shape (checkout with full history, set up Node, conditional check) for other CIs. -Substitute `%(PACKAGE_MANAGER_DLX)s` with the CI runner's own launcher — -`npx`, `pnpm dlx`, `yarn dlx`, or `bunx` based on -`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb` — followed by the same package -`%(TASKLESS_CLI)s` names. That is a question about the repository you are -wiring up, not about how this recipe was fetched, which is why it stays a -separate placeholder. +Substitute `%(PACKAGE_MANAGER_DLX)s` with the CI runner's own complete +invocation — `npx @taskless/cli`, `pnpm dlx @taskless/cli`, +`yarn dlx @taskless/cli`, or `bunx @taskless/cli` — picking the launcher +from `pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`. It is a whole command on +its own, exactly as the template uses it; never combine it with +`%(TASKLESS_CLI)s`. Which launcher the repository you are wiring up should +run is a different question from how this recipe was fetched, which is why +it stays a separate placeholder. ```yaml name: Taskless @@ -194,7 +196,9 @@ the user explicitly asks to run authenticated commands (e.g. - **pnpm**: `pnpm dlx` works but has slow cold starts. If the user's pipeline already sets up pnpm, suggest adding `@taskless/cli` as a - dev dep and calling it via `pnpm %(TASKLESS_CLI)s check`. + dev dep and calling it via `pnpm taskless check`. That `taskless` is the + binary pnpm resolves from the wired repo's own `node_modules/.bin`, not + this CLI's invocation, so it is not `%(TASKLESS_CLI)s`. - **Yarn v1 (classic)**: doesn't support `yarn dlx`. Use `npx`. - **Bun**: `bunx` works. From e40d55480e14571871f99b9cffa2f85a451879aa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 11:18:57 -0700 Subject: [PATCH 07/12] refactor(cli): finish naming the CLI by invocation everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize the remaining eleven recipes onto %(TASKLESS_CLI)s, and fold in the three places a recipe named the CLI with no subcommand at all — init.txt's wizard launch, info.txt's reinstall suggestion, and update.txt's `@latest` reference. No bare `taskless ` and no hardcoded `npx @taskless/cli` remains under src/agent/. skills/taskless/SKILL.md and commands/tskl/tskl.md are treated differently, and deliberately. Neither goes through the sprintf renderer — src/install/ canonical.ts emits them through applyCliInvocation alone — so a %(TASKLESS_CLI)s placeholder there would ship to disk unrendered. They keep the literal `npx @taskless/cli`, which the build target already rewrites. The one real defect in them was SKILL.md's bare `taskless agent route`, the single spelling applyCliInvocation cannot see; it is now the rewritable form. The trigger phrases in SKILL.md's frontmatter ("run taskless", "taskless check") stay as they are. They are what a user says, not what an agent runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- openspec/changes/full-cli-invocation/tasks.md | 6 +++--- packages/cli/src/agent/auth.txt | 16 ++++++++-------- packages/cli/src/agent/check.txt | 14 +++++++------- packages/cli/src/agent/delete-rule.txt | 6 +++--- packages/cli/src/agent/detect.txt | 12 ++++++------ .../cli/src/agent/improve-rule.anonymous.txt | 10 +++++----- packages/cli/src/agent/info.txt | 10 +++++----- packages/cli/src/agent/init.txt | 8 ++++---- packages/cli/src/agent/rule-meta.txt | 4 ++-- packages/cli/src/agent/rule.txt | 12 ++++++------ packages/cli/src/agent/update.txt | 10 +++++----- packages/cli/src/agent/verify-rule.txt | 10 +++++----- skills/taskless/SKILL.md | 2 +- 13 files changed, 60 insertions(+), 60 deletions(-) diff --git a/openspec/changes/full-cli-invocation/tasks.md b/openspec/changes/full-cli-invocation/tasks.md index 9acff20c..c5e9367b 100644 --- a/openspec/changes/full-cli-invocation/tasks.md +++ b/openspec/changes/full-cli-invocation/tasks.md @@ -28,9 +28,9 @@ Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branc ## 4. PR 4 — recipe normalization, part B -- [ ] 4.1 Normalize the remaining recipes the same way -- [ ] 4.2 Normalize `skills/taskless/SKILL.md` and `commands/tskl/tskl.md`. These are not rendered through the recipe renderer, so they keep the literal `npx @taskless/cli` that `applyCliInvocation` already rewrites — verify which of the two treatments each file needs rather than assuming -- [ ] 4.3 Verify no recipe still names a bare `taskless` binary +- [x] 4.1 Normalize the remaining recipes the same way +- [x] 4.2 Normalize `skills/taskless/SKILL.md` and `commands/tskl/tskl.md`. These are not rendered through the recipe renderer, so they keep the literal `npx @taskless/cli` that `applyCliInvocation` already rewrites — verify which of the two treatments each file needs rather than assuming +- [x] 4.3 Verify no recipe still names a bare `taskless` binary ## 5. PR 5 (tip) — cross-reference checking and the archive diff --git a/packages/cli/src/agent/auth.txt b/packages/cli/src/agent/auth.txt index 7376aa41..58db835c 100644 --- a/packages/cli/src/agent/auth.txt +++ b/packages/cli/src/agent/auth.txt @@ -20,14 +20,14 @@ Pick the branch matching the user's intent. 1. Run: ``` - npx @taskless/cli auth login + %(TASKLESS_CLI)s auth login ``` 2. The CLI prints a URL and a device code. Tell the user to open the URL and enter the code. 3. The CLI polls until the token is approved. On success, the token is written to `.taskless/.env.local.json` and a confirmation is printed. -4. Report success. Suggest `taskless info` to verify identity. +4. Report success. Suggest `%(TASKLESS_CLI)s info` to verify identity. The `--anonymous` flag is rejected on `auth login` — it errors with "auth commands cannot be anonymous". Don't pass it. @@ -36,7 +36,7 @@ The `--anonymous` flag is rejected on `auth login` — it errors with 1. Run: ``` - npx @taskless/cli auth logout + %(TASKLESS_CLI)s auth logout ``` 2. The CLI removes the saved token (or reports "Not logged in" if none was present). @@ -46,7 +46,7 @@ The `--anonymous` flag is rejected on `auth login` — it errors with 1. Run: ``` - npx @taskless/cli auth + %(TASKLESS_CLI)s auth ``` 2. Output is one of: - "Not logged in." (with hint to run `auth login`) @@ -61,7 +61,7 @@ The `--anonymous` flag is rejected on `auth login` — it errors with standardized `{ ok: false, code, message }` envelope is written to stdout (and human text on stderr is suppressed). On success in `--json` mode, the commands exit 0 silently — no success envelope is -emitted. The status path (`taskless auth` with no subcommand) accepts +emitted. The status path (`%(TASKLESS_CLI)s auth` with no subcommand) accepts `--json` for forward-compat but currently has no error paths to report. @@ -69,9 +69,9 @@ report. |------------------|-------------------------------------------------------------------------------------------|------------------------------| | `INVALID_INPUT` | `--anonymous` passed to `auth login` (rejected: auth commands cannot be anonymous) | Don't pass `--anonymous` | | `NETWORK_ERROR` | Device flow / token endpoint unreachable, or the device code expired before approval | Check connectivity; retry | -| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Re-run `taskless auth login` | +| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Re-run `%(TASKLESS_CLI)s auth login` | ## See Also -- `taskless agent info` — see auth state and skill versions -- `taskless agent route` — first action that requires auth +- `%(TASKLESS_CLI)s agent info` — see auth state and skill versions +- `%(TASKLESS_CLI)s agent route` — first action that requires auth diff --git a/packages/cli/src/agent/check.txt b/packages/cli/src/agent/check.txt index ad31b58e..43e715f6 100644 --- a/packages/cli/src/agent/check.txt +++ b/packages/cli/src/agent/check.txt @@ -11,7 +11,7 @@ in CI (diff-only scan), or after rule create/improve to validate. - `.taskless/` directory exists. - At least one rule exists in `.taskless/rules/sg/` or `.taskless/rules/runtime/`. (If none exist, the CLI exits 0 with a - friendly message suggesting `taskless rule create`.) + friendly message suggesting `%(TASKLESS_CLI)s rule create`.) - No auth required. Static rules always run; whether runtime rules run depends on auth state — see "What runs". @@ -40,7 +40,7 @@ only: they never change the exit code. Under `--json` they do NOT appear as warnings; instead an additive optional `skipped: [{ rule, reason }]` array is included alongside the unchanged `{ success, results }`. The authoritative allow-list is the server's; the CI backstop -(`taskless agent ci`) is the enforcement point for runtime rules. +(`%(TASKLESS_CLI)s agent ci`) is the enforcement point for runtime rules. ## Flags - `--json` — machine output (`{ success, results, skipped? }`). @@ -56,15 +56,15 @@ authoritative allow-list is the server's; the CI backstop 2. **Invoke the CLI.** Either: ``` - npx @taskless/cli check --json + %(TASKLESS_CLI)s check --json ``` or, scoped to specific paths: ``` - npx @taskless/cli check --json src/foo.ts src/bar.ts + %(TASKLESS_CLI)s check --json src/foo.ts src/bar.ts ``` or, against a git diff: ``` - npx @taskless/cli check --json $(git diff --name-only main...HEAD) + %(TASKLESS_CLI)s check --json $(git diff --name-only main...HEAD) ``` Paths that don't exist on disk are silently filtered, so you can pipe raw `git diff` output directly without pre-filtering. @@ -125,5 +125,5 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: ## See Also -- `taskless agent route` — add a rule if none exist -- `taskless agent ci` — wire `check` into a CI pipeline +- `%(TASKLESS_CLI)s agent route` — add a rule if none exist +- `%(TASKLESS_CLI)s agent ci` — wire `check` into a CI pipeline diff --git a/packages/cli/src/agent/delete-rule.txt b/packages/cli/src/agent/delete-rule.txt index 3e9f8f94..7cae2a7e 100644 --- a/packages/cli/src/agent/delete-rule.txt +++ b/packages/cli/src/agent/delete-rule.txt @@ -22,7 +22,7 @@ rule is deleted by removing its directory by hand. 2. **Invoke the CLI.** Run: ``` - npx @taskless/cli rule delete + %(TASKLESS_CLI)s rule delete ``` The CLI removes: @@ -49,5 +49,5 @@ emitted from this command. ## See Also -- `taskless agent route` — make a new rule -- `taskless agent check` — run remaining rules to confirm nothing broke +- `%(TASKLESS_CLI)s agent route` — make a new rule +- `%(TASKLESS_CLI)s agent check` — run remaining rules to confirm nothing broke diff --git a/packages/cli/src/agent/detect.txt b/packages/cli/src/agent/detect.txt index 8470be5a..8209dd45 100644 --- a/packages/cli/src/agent/detect.txt +++ b/packages/cli/src/agent/detect.txt @@ -14,7 +14,7 @@ routing flow reads `detect` to decide where a new rule should live. 1. **Invoke the CLI** with JSON output: ``` - npx @taskless/cli detect --json + %(TASKLESS_CLI)s detect --json ``` 2. **Parse the response.** Shape: @@ -45,9 +45,9 @@ routing flow reads `detect` to decide where a new rule should live. 3. **Use the signals to route.** Feed the output into rule authoring: - A detected linter the repo already uses → author the rule there - (`taskless agent route`). - - No suitable linter, local-only → `taskless agent create-sg-rule`. - - See `taskless agent route` for the full decision. + (`%(TASKLESS_CLI)s agent route`). + - No suitable linter, local-only → `%(TASKLESS_CLI)s agent create-sg-rule`. + - See `%(TASKLESS_CLI)s agent route` for the full decision. ## Errors @@ -59,5 +59,5 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: ## See Also -- `taskless agent route` — decide where to author a rule from these signals -- `taskless agent check` — run rules against the codebase +- `%(TASKLESS_CLI)s agent route` — decide where to author a rule from these signals +- `%(TASKLESS_CLI)s agent check` — run rules against the codebase diff --git a/packages/cli/src/agent/improve-rule.anonymous.txt b/packages/cli/src/agent/improve-rule.anonymous.txt index c2d3709a..6dffc8fd 100644 --- a/packages/cli/src/agent/improve-rule.anonymous.txt +++ b/packages/cli/src/agent/improve-rule.anonymous.txt @@ -42,7 +42,7 @@ validate with `verify` and `test` in a feedback loop. 6. **Run the verify feedback loop.** Run: ``` - npx @taskless/cli test .taskless/rules/sg/ --json + %(TASKLESS_CLI)s test .taskless/rules/sg/ --json ``` - If `success: true`: report success. - If `success: false`: read the per-layer errors, fix, re-run. @@ -60,7 +60,7 @@ validate with `verify` and `test` in a feedback loop. - The updated rule file path - The updated test file path - A diff-style summary of what changed - Suggest fetching `taskless agent check` to validate against the + Suggest fetching `%(TASKLESS_CLI)s agent check` to validate against the broader codebase. ## Important Notes @@ -84,6 +84,6 @@ The verify primitive returns structured errors per layer: ## See Also -- `taskless agent improve-rule` — API-backed flow (auth required) -- `taskless agent create-sg-rule` — make a new rule locally -- `taskless agent check` — validate the updated rule +- `%(TASKLESS_CLI)s agent improve-rule` — API-backed flow (auth required) +- `%(TASKLESS_CLI)s agent create-sg-rule` — make a new rule locally +- `%(TASKLESS_CLI)s agent check` — validate the updated rule diff --git a/packages/cli/src/agent/info.txt b/packages/cli/src/agent/info.txt index 0544de93..a611e84f 100644 --- a/packages/cli/src/agent/info.txt +++ b/packages/cli/src/agent/info.txt @@ -13,12 +13,12 @@ which version of the CLI/skills the agent is talking to. 1. **Invoke the CLI** with JSON output: ``` - npx @taskless/cli info --json + %(TASKLESS_CLI)s info --json ``` For an offline/local-only state report (no auth probe), pass `--anonymous`: ``` - npx @taskless/cli info --json --anonymous + %(TASKLESS_CLI)s info --json --anonymous ``` 2. **Parse the response.** Shape: @@ -46,7 +46,7 @@ which version of the CLI/skills the agent is talking to. - Auth: logged in as (orgs) OR not logged in 4. **Suggest reinit on staleness.** If any skill has `current: false`, - suggest `npx @taskless/cli` to reinstall and pull the latest + suggest `%(TASKLESS_CLI)s` to reinstall and pull the latest bundle. ## Errors @@ -63,5 +63,5 @@ failing.) ## See Also -- `taskless agent auth` — log in / log out / status detail -- `taskless agent check` — run rules against the codebase +- `%(TASKLESS_CLI)s agent auth` — log in / log out / status detail +- `%(TASKLESS_CLI)s agent check` — run rules against the codebase diff --git a/packages/cli/src/agent/init.txt b/packages/cli/src/agent/init.txt index 04c05a68..c1cb2ccc 100644 --- a/packages/cli/src/agent/init.txt +++ b/packages/cli/src/agent/init.txt @@ -15,14 +15,14 @@ right command when they need to install or upgrade. The user should run: ``` -npx @taskless/cli +%(TASKLESS_CLI)s ``` (no subcommand). In a TTY this launches the interactive wizard. In non-TTY contexts it prints the topic index instead. For scripted installs (CI, Dockerfiles): ``` -npx @taskless/cli init --no-interactive +%(TASKLESS_CLI)s init --no-interactive ``` The wizard will: @@ -50,5 +50,5 @@ what was removed. ## See Also -- `taskless agent info` — verify what's installed and check staleness -- `taskless agent auth` — authenticate after installing +- `%(TASKLESS_CLI)s agent info` — verify what's installed and check staleness +- `%(TASKLESS_CLI)s agent auth` — authenticate after installing diff --git a/packages/cli/src/agent/rule-meta.txt b/packages/cli/src/agent/rule-meta.txt index 023c0c10..19355ab9 100644 --- a/packages/cli/src/agent/rule-meta.txt +++ b/packages/cli/src/agent/rule-meta.txt @@ -12,7 +12,7 @@ the `rule improve` recipe to fetch the `ticketId` needed for iteration. ## Steps ``` -npx @taskless/cli rule meta --json +%(TASKLESS_CLI)s rule meta --json ``` Returns the metadata fields: `ticketId`, `generatedAt`, schema @@ -27,4 +27,4 @@ version, etc. ## See Also -- `taskless agent improve-rule` — the primary consumer of this command +- `%(TASKLESS_CLI)s agent improve-rule` — the primary consumer of this command diff --git a/packages/cli/src/agent/rule.txt b/packages/cli/src/agent/rule.txt index 26acc655..dbbd35f3 100644 --- a/packages/cli/src/agent/rule.txt +++ b/packages/cli/src/agent/rule.txt @@ -7,11 +7,11 @@ Umbrella for rule operations. Fetch the topic for the action you want. | Action | Recipe | |---------------------|-----------------------------------------------| -| Create a rule | `taskless agent route` | -| Improve a rule | `taskless agent improve-rule` | -| Delete a rule | `taskless agent delete-rule` | -| Verify a rule | `taskless agent verify-rule` (agent-internal) | -| Read rule metadata | `taskless agent rule-meta` (agent-internal) | +| Create a rule | `%(TASKLESS_CLI)s agent route` | +| Improve a rule | `%(TASKLESS_CLI)s agent improve-rule` | +| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | +| Verify a rule | `%(TASKLESS_CLI)s agent verify-rule` (agent-internal) | +| Read rule metadata | `%(TASKLESS_CLI)s agent rule-meta` (agent-internal) | `route` is the entry point for authoring: it reads the request and names the `create-*-rule` topic that fits, so you do not pick an engine @@ -21,4 +21,4 @@ For the local-only flow on improve, append `--anonymous`. ## See Also -- `taskless agent check` — run all configured rules +- `%(TASKLESS_CLI)s agent check` — run all configured rules diff --git a/packages/cli/src/agent/update.txt b/packages/cli/src/agent/update.txt index 4bf75b3c..90f6186c 100644 --- a/packages/cli/src/agent/update.txt +++ b/packages/cli/src/agent/update.txt @@ -4,12 +4,12 @@ Update Taskless skills in the user's coding-agent tools to the latest bundled version. Non-interactive — no wizard, no prompts. Installs to all detected tool locations using the same logic as -`taskless init --no-interactive`, but exposed as its own subcommand +`%(TASKLESS_CLI)s init --no-interactive`, but exposed as its own subcommand so the agent can run it directly without explaining flags. This is the right command when the user has Taskless already installed and just wants to refresh to a new version (e.g. after -running `npx @taskless/cli@latest`). +running `%(TASKLESS_CLI)s`). ## Preconditions - None at the user level. Works in any directory. @@ -20,7 +20,7 @@ running `npx @taskless/cli@latest`). ## Steps ``` -npx @taskless/cli update +%(TASKLESS_CLI)s update ``` The CLI: @@ -44,5 +44,5 @@ non-zero with the error message on stderr. ## See Also -- `taskless agent init` — interactive variant (wizard with prompts) -- `taskless agent info` — verify what's installed and check staleness +- `%(TASKLESS_CLI)s agent init` — interactive variant (wizard with prompts) +- `%(TASKLESS_CLI)s agent info` — verify what's installed and check staleness diff --git a/packages/cli/src/agent/verify-rule.txt b/packages/cli/src/agent/verify-rule.txt index 6a90be6c..c800b756 100644 --- a/packages/cli/src/agent/verify-rule.txt +++ b/packages/cli/src/agent/verify-rule.txt @@ -14,8 +14,8 @@ but agents can call either directly. ## The two commands ``` -npx @taskless/cli verify --json -npx @taskless/cli test --json +%(TASKLESS_CLI)s verify --json +%(TASKLESS_CLI)s test --json ``` `verify` asks whether the rule has the components its engine requires. @@ -90,6 +90,6 @@ it still fails. ## See Also -- `taskless agent create-sg-rule` — author an ast-grep rule -- `taskless agent create-vale-rule` — author a Vale rule -- `taskless agent improve-rule` — iterate on a rule that already exists +- `%(TASKLESS_CLI)s agent create-sg-rule` — author an ast-grep rule +- `%(TASKLESS_CLI)s agent create-vale-rule` — author a Vale rule +- `%(TASKLESS_CLI)s agent improve-rule` — iterate on a rule that already exists diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 5f03bf09..ab04b829 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -17,7 +17,7 @@ description: | Also trigger on any request to add/write/create a lint or code rule, including ones that name a specific tool (eslint, ruff, biome, stylelint, ast-grep). Naming a tool ENGAGES this skill's routing flow via - `taskless agent route`; it does NOT suppress the skill. + `npx @taskless/cli agent route`; it does NOT suppress the skill. metadata: author: taskless version: 0.10.2 From e4785342a579f409e67192de4abc3417c06163cf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 11:21:42 -0700 Subject: [PATCH 08/12] test(cli): check cross-references on rendered recipes, guard the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The citation checks read rendered recipes now instead of sources. A recipe names the CLI as %(TASKLESS_CLI)s, and the citation pattern is anchored on a package or binary name — that anchor is what keeps prose ("the agent should…") from being read as a citation, and it cannot see through a placeholder. Rendering first turns the invocation into a literal the pattern can anchor on, and it is also the text an agent is actually handed. Add the regression guard, deliberately over SOURCE. The failure it prevents is an author writing the invocation out by hand, and a hardcoded `npx @taskless/cli` is invisible after rendering under a prod build — it is byte-identical to what a correct %(TASKLESS_CLI)s produces. Only the source tells the two apart. Verified by appending a bare `taskless check` line to a recipe: the guard fails and names info.txt and the line number. Archive the change and sync the three capability specs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/cli-agent/spec.md | 0 .../specs/cli-knowledge-prompts/spec.md | 0 .../specs/cli/spec.md | 0 .../2026-08-23-full-cli-invocation}/tasks.md | 8 +- openspec/specs/cli-agent/spec.md | 52 +++++++++ openspec/specs/cli-knowledge-prompts/spec.md | 57 ++++++++- openspec/specs/cli/spec.md | 51 ++++++++ .../cli/test/recipe-cross-references.test.ts | 110 +++++++++++++++--- 10 files changed, 256 insertions(+), 22 deletions(-) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/.openspec.yaml (100%) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/proposal.md (100%) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/specs/cli-agent/spec.md (100%) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/specs/cli-knowledge-prompts/spec.md (100%) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/specs/cli/spec.md (100%) rename openspec/changes/{full-cli-invocation => archive/2026-08-23-full-cli-invocation}/tasks.md (95%) diff --git a/openspec/changes/full-cli-invocation/.openspec.yaml b/openspec/changes/archive/2026-08-23-full-cli-invocation/.openspec.yaml similarity index 100% rename from openspec/changes/full-cli-invocation/.openspec.yaml rename to openspec/changes/archive/2026-08-23-full-cli-invocation/.openspec.yaml diff --git a/openspec/changes/full-cli-invocation/proposal.md b/openspec/changes/archive/2026-08-23-full-cli-invocation/proposal.md similarity index 100% rename from openspec/changes/full-cli-invocation/proposal.md rename to openspec/changes/archive/2026-08-23-full-cli-invocation/proposal.md diff --git a/openspec/changes/full-cli-invocation/specs/cli-agent/spec.md b/openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli-agent/spec.md similarity index 100% rename from openspec/changes/full-cli-invocation/specs/cli-agent/spec.md rename to openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli-agent/spec.md diff --git a/openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md b/openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli-knowledge-prompts/spec.md similarity index 100% rename from openspec/changes/full-cli-invocation/specs/cli-knowledge-prompts/spec.md rename to openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli-knowledge-prompts/spec.md diff --git a/openspec/changes/full-cli-invocation/specs/cli/spec.md b/openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli/spec.md similarity index 100% rename from openspec/changes/full-cli-invocation/specs/cli/spec.md rename to openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli/spec.md diff --git a/openspec/changes/full-cli-invocation/tasks.md b/openspec/changes/archive/2026-08-23-full-cli-invocation/tasks.md similarity index 95% rename from openspec/changes/full-cli-invocation/tasks.md rename to openspec/changes/archive/2026-08-23-full-cli-invocation/tasks.md index c5e9367b..a001489e 100644 --- a/openspec/changes/full-cli-invocation/tasks.md +++ b/openspec/changes/archive/2026-08-23-full-cli-invocation/tasks.md @@ -34,7 +34,7 @@ Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branc ## 5. PR 5 (tip) — cross-reference checking and the archive -- [ ] 5.1 Move `test/recipe-cross-references.test.ts` from scanning recipe source to scanning rendered recipes, where the invocation is a stable literal. Match the marker alongside the existing spellings -- [ ] 5.2 Add the regression guard: no bare `` `taskless ` `` in any `src/agent/*.txt`, naming the offending file and line -- [ ] 5.3 Archive the change to `openspec/changes/archive/-full-cli-invocation/` -- [ ] 5.4 Run every gate on the tip: build, test, typecheck, lint, `openspec validate --strict` +- [x] 5.1 Move `test/recipe-cross-references.test.ts` from scanning recipe source to scanning rendered recipes, where the invocation is a stable literal. Match the marker alongside the existing spellings +- [x] 5.2 Add the regression guard: no bare `` `taskless ` `` in any `src/agent/*.txt`, naming the offending file and line +- [x] 5.3 Archive the change to `openspec/changes/archive/-full-cli-invocation/` +- [x] 5.4 Run every gate on the tip: build, test, typecheck, lint, `openspec validate --strict` diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index cc90b9d1..fbd1de47 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -82,6 +82,14 @@ Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named- 1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic. 2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as ``). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention. +The renderer SHALL additionally provide `TASKLESS_CLI` for every render. It is a hybrid of the two flavors: system-resolved when the caller or the build knows the answer, and an agent-fill marker when neither does. It SHALL resolve in this order: + +1. The caller-supplied invocation, when one is given. +2. The build-target invocation, when the build target is not prod — a `nightly`, `dev`, or `self` build knows exactly what it is and SHALL name itself. +3. Otherwise the agent-fill marker ``. + +Step 3 SHALL NOT fall back to `npx @taskless/cli`. A prod build that does not know how it was launched has no basis for naming one launcher over another, and a marker asks the reading agent for the answer instead of asserting a wrong one. + Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table. #### Scenario: CLI_VERSION substitutes the build-time version @@ -100,6 +108,22 @@ Recipe authors SHALL escape any literal `%` character in recipe content as `%%` - **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s` - **THEN** the rendered output SHALL contain the literal token `` at every occurrence +#### Scenario: TASKLESS_CLI renders a caller-supplied invocation + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with an explicit invocation +- **THEN** every occurrence SHALL render as that invocation + +#### Scenario: TASKLESS_CLI names a non-prod build target + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with no explicit invocation from a `nightly`, `dev`, or `self` build +- **THEN** every occurrence SHALL render as that build's own invocation, so a nightly names `@taskless/cli-nightly` at its published version rather than the released package + +#### Scenario: TASKLESS_CLI falls back to an agent-fill marker + +- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered from a prod build with no explicit invocation +- **THEN** every occurrence SHALL render as the literal token `` +- **AND** SHALL NOT render as `npx @taskless/cli` or any other guessed launcher + #### Scenario: No legacy placeholder syntax remains in recipes - **WHEN** any `.txt` file under `packages/cli/src/agent/` is read @@ -242,3 +266,31 @@ No embedded recipe SHALL contain the string `taskless help`. Recipes cross-refer - **WHEN** the embedded recipe set is inspected - **THEN** no recipe SHALL contain `taskless help` + +### Requirement: Recipes name the CLI by its full invocation + +Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL express the CLI as `%(TASKLESS_CLI)s`, followed by the subcommand and its arguments. This applies to the recipe sources under `packages/cli/src/agent/`, to `skills/taskless/SKILL.md`, and to `commands/tskl/tskl.md`. + +Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers, and SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli`, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. + +An automated check SHALL fail when a bare `` `taskless ` `` invocation appears in a recipe source, so the normalization cannot silently regress as recipes are edited. + +#### Scenario: A recipe instructs the reader to run a subcommand + +- **WHEN** a recipe tells the reader to fetch another topic +- **THEN** the source SHALL read `%(TASKLESS_CLI)s agent ` rather than `taskless agent ` or `npx @taskless/cli agent ` + +#### Scenario: A regression is reintroduced + +- **WHEN** a recipe source is edited to contain a bare `` `taskless check` ``-style invocation +- **THEN** the automated check SHALL fail and name the offending file and line + +#### Scenario: Non-prod builds rewrite every invocation, not some of them + +- **WHEN** a `nightly` or `self` build renders any recipe +- **THEN** every CLI invocation in the rendered text SHALL name that build's own invocation, with no occurrence left naming the released `@taskless/cli` + +#### Scenario: Cross-reference checking survives the normalization + +- **WHEN** the check that recipe cross-references cite only topics that resolve is run +- **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index d3d171bf..ec9d84e6 100644 --- a/openspec/specs/cli-knowledge-prompts/spec.md +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -52,7 +52,9 @@ The prompt export SHALL be sourced from the same embedded `agent/*.txt` content ### 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. +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 `TASKLESS_CLI` from `PromptOptions.invocation`, the non-prod build-target invocation, 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. + +`PromptOptions.invocation` SHALL be the only way a consumer influences `TASKLESS_CLI`. The render path SHALL NOT read `process`, the environment, or `argv` to discover the answer for itself: the module is imported by Workers without `nodejs_compat`, where a module-scope `process` read throws at import time, and a build-graph check cannot catch that because `process` is a global rather than an import. Detection therefore lives in the CLI, which passes the result in. #### Scenario: Placeholders are resolved @@ -61,7 +63,7 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub #### 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) +- **WHEN** a recipe carrying `%(INPUT_SCHEMA)s` is rendered (today `create-remote-rule` and `improve-rule`, 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 @@ -69,6 +71,16 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub - **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 +#### Scenario: A host importing the export gets the marker + +- **WHEN** a consumer imports `@taskless/cli/prompts` from a prod build and renders a topic without supplying `invocation` +- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as ``, leaving the host free to substitute the launcher it actually offers + +#### Scenario: A supplied invocation is used verbatim + +- **WHEN** a consumer supplies `invocation: "pnpm dlx @taskless/cli@latest"` +- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as that string + ### 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 `agent` command's output and all existing behavior unchanged. @@ -164,3 +176,44 @@ A consumer that can decide a rule belongs to an engine must be able to reach the - **WHEN** a consumer imports `TOPICS` - **THEN** it SHALL NOT contain `static` or `engine-selection`, neither of which names a recipe any more + +### Requirement: The export provides rendered and raw instruction accessors + +The export SHALL provide `getInstructions(topic, options?)` and `getRawInstructions(topic, options?)`, each returning `{ text: string; variables: string[] }`. + +`getInstructions` SHALL return finished text — the same string `getPrompt` returns for the same topic and options — alongside the names of the sprintf variables the topic's template contains. `getRawInstructions` SHALL return the **unrendered** template text alongside the same variable names, so a host that knows a value the package cannot know can render it itself. + +Both SHALL throw on a topic with no embedded recipe, matching `getPrompt`. The existing internal `getRecipe` accessor SHALL continue to return `undefined` for an unknown topic; the `agent` command distinguishes an unknown topic from a failure and cannot use a throwing accessor. + +`variables` SHALL be identical between the two functions for the same topic, since both describe the same template. + +#### Scenario: Rendered instructions match the existing accessor + +- **WHEN** a consumer calls `getInstructions(t)` and `getPrompt(t)` for the same topic and options +- **THEN** `getInstructions(t).text` SHALL equal `getPrompt(t)` + +#### Scenario: Raw instructions are re-renderable + +- **WHEN** a consumer renders `getRawInstructions(t).text` with sprintf-js against the same variable values the package used +- **THEN** the result SHALL equal `getInstructions(t).text` + +#### Scenario: An unknown topic throws + +- **WHEN** either accessor is called with a topic that has no embedded recipe +- **THEN** it SHALL throw, rather than returning an empty or undefined result + +### Requirement: The variable list is derived from the template parser + +The `variables` list SHALL be obtained from `sprintf-js`'s own parse of the template, by rendering it against a value source that records which names are requested. It SHALL NOT be reconstructed by pattern-matching the template text. + +The recorded pass's rendered output SHALL be discarded. `sprintf-js` collapses an escaped `%%` to a literal `%` while parsing, so text that has been through it is no longer a valid template — `getRawInstructions().text` SHALL therefore be the source template verbatim, never the recording pass's output. + +#### Scenario: Escaped percent signs survive in raw text + +- **WHEN** a topic whose template contains `%%` is requested via `getRawInstructions` +- **THEN** the returned text SHALL still contain `%%`, and rendering it SHALL yield a single `%` + +#### Scenario: Variables are reported without a regex + +- **WHEN** the variable list for a topic is computed +- **THEN** it SHALL come from the templating library's parse, so a name the library resolves is reported and text that merely resembles a placeholder is not diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index f3a927e3..dd567826 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -432,3 +432,54 @@ The declaration SHALL NOT be a `devDependency`, which would not be installed for - **WHEN** a platform package is published for a newer upstream Vale release and the CLI's pin is unchanged - **THEN** the CLI continues to resolve the pinned version + +### Requirement: The CLI determines how it was launched from the path it was launched from + +The CLI SHALL determine the command a user would type to reach it again from the shape of `process.argv[1]` together with the process environment, and SHALL expose that determination as a function that is pure over an injected context — an environment record and an argv array — so every launcher case is testable without spawning a process. This mirrors `resolveBuildTarget`, which is pure over an injected `BuildEnvironment` for the same reason. + +The detection SHALL recognize two launchers: + +- **npx**, identified by an `_npx` cache path segment in `argv[1]`, or by the environment reporting an `exec` command whose lifecycle event is `npx`. +- **pnpm dlx**, identified by a `pnpm/` user agent together with a `dlx` cache path segment in `argv[1]`. + +The detection SHALL return "unknown" for everything else, and "unknown" SHALL be a first-class answer rather than a fallback to npx. In particular, `pnpm run`, `pnpm exec`, and pnpm lifecycle scripts all set a `pnpm/` user agent while running the CLI out of the repository's own `node_modules`; they SHALL NOT be reported as `pnpm dlx`. A bare `node` invocation, a `node_modules/.bin` shim, and a global install set no distinguishing signal and SHALL all be reported as unknown. + +Yarn and bun are deliberately not detected. They are distinguishable only by the user agent, which the pnpm case demonstrates is not evidence of how the user invoked anything. + +#### Scenario: An npx launch is recognized + +- **WHEN** the CLI is launched by `npx` and `argv[1]` lies under the npx cache +- **THEN** detection SHALL report an npx launch + +#### Scenario: A pnpm dlx launch is recognized + +- **WHEN** the CLI is launched by `pnpm dlx` and `argv[1]` lies under pnpm's dlx cache +- **THEN** detection SHALL report a pnpm dlx launch + +#### Scenario: A pnpm script is not mistaken for pnpm dlx + +- **WHEN** the CLI runs from a `package.json` script under pnpm, with a `pnpm/` user agent and an `argv[1]` inside the repository's `node_modules` +- **THEN** detection SHALL report unknown, not pnpm dlx + +#### Scenario: A bare launch is unknown + +- **WHEN** the CLI is launched with no package-manager environment at all +- **THEN** detection SHALL report unknown + +### Requirement: User-facing CLI invocations name the package the reader is running + +Wherever the CLI prints a command for the user to run — an authentication prompt, a re-authentication hint, an error remedy — it SHALL compose that command from the detected launcher and from the package specifier of the build in hand, not from a hardcoded string. + +The package specifier SHALL come from the build-target invocation, so a nightly build names `@taskless/cli-nightly` at its published version and a prod build names `@taskless/cli`. A prod specifier SHALL be pinned to `@latest` when it is handed to a launcher, since `npx` and `pnpm dlx` otherwise prefer whatever is already cached. A build whose invocation is a filesystem path (`dev`, `self`) SHALL be printed verbatim, since no launcher applies to it. + +Where detection reports unknown, the printed command SHALL name `npx` with the correct package specifier. That is a display default for a human reader who needs something runnable, and it is distinct from the recipe renderer's marker, which is read by an agent that can be asked to supply the right answer. + +#### Scenario: A nightly names itself in an error message + +- **WHEN** a nightly build prints an authentication remedy +- **THEN** the printed command SHALL name `@taskless/cli-nightly` at the nightly's own version, not `@taskless/cli` + +#### Scenario: A pnpm script no longer suggests pnpm dlx + +- **WHEN** the CLI runs from a `package.json` script under pnpm and prints an authentication remedy +- **THEN** the printed command SHALL NOT suggest `pnpm dlx`, because that is not how the reader reached the CLI diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index e0b31e39..d7bdd8a7 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -2,6 +2,8 @@ import { readdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { getRecipe } from "../src/prompts/recipes"; + /** * Recipes cite each other by topic name, in prose. Nothing resolves those * citations at build time — they are strings an agent reads and then types — @@ -9,13 +11,38 @@ import { describe, expect, it } from "vitest"; * only when an agent runs it and gets a non-zero exit and no recipe. * * That is the failure mode the `taskless help` → `taskless agent` rename - * created 90 opportunities for. These read the recipe sources, which are the - * authority for what ships, rather than a built bundle: the bundler resolves - * imports, and a prose cross-reference is not one, so there is no structured - * answer to ask it for. + * created 90 opportunities for. + * + * The citation checks read RENDERED recipes, not sources. A recipe names the + * CLI as `%(TASKLESS_CLI)s`, and a pattern anchored on a package or binary + * name — the thing that keeps prose ("the agent should…") from being read as a + * citation — cannot see through a placeholder. Rendering first turns the + * invocation into a literal the pattern can anchor on, and it is also the text + * an agent is actually handed. The source is still the authority for the + * checks that are about how recipes are *written*, which is why the guard + * below reads it directly. */ const recipeDirectory = resolve(import.meta.dirname, "../src/agent"); +/** The CLI's subcommands, as a recipe would name one. */ +const SUBCOMMANDS = + "agent|auth|check|detect|info|init|onboard|rule|test|update|verify"; + +/** + * A CLI invocation spelled out in a recipe source instead of deferred to + * `%(TASKLESS_CLI)s`: a bare `taskless ` binary that is not on the + * reader's PATH, or a hardcoded `npx @taskless/cli ` that pins the + * released package into a nightly's own instructions. + * + * The lookbehind is what keeps `.taskless/rules/…` and `@taskless/cli` from + * matching the bare form. + */ +const HARDCODED_INVOCATION = new RegExp( + String.raw`npx @taskless/cli|(? { const entries = await readdir(recipeDirectory); return entries.filter((entry) => entry.endsWith(".txt")).toSorted(); @@ -31,13 +58,23 @@ async function embeddedTopics(): Promise> { ); } +/** Every shipped recipe as an agent receives it, keyed by its source file. */ +async function renderedRecipes(): Promise> { + const files = await recipeFiles(); + return files.map((file) => { + const stem = file.replace(/\.txt$/, ""); + const anonymous = stem.endsWith(".anonymous"); + const topic = stem.replace(/\.anonymous$/, ""); + return [file, getRecipe(topic, { anonymous }) ?? ""]; + }); +} + describe("shipped recipes name only commands that exist", () => { it("contains no reference to the former `taskless help` command", async () => { const offenders: string[] = []; - for (const file of await recipeFiles()) { - const source = await readFile(join(recipeDirectory, file), "utf8"); - for (const [index, line] of source.split("\n").entries()) { - if (/(?:taskless|@taskless\/cli) help\b/.test(line)) { + for (const [file, rendered] of await renderedRecipes()) { + for (const [index, line] of rendered.split("\n").entries()) { + if (/(?:taskless|@taskless\/cli|) help\b/.test(line)) { offenders.push(`${file}:${String(index + 1)}: ${line.trim()}`); } } @@ -51,15 +88,15 @@ describe("shipped recipes name only commands that exist", () => { const topics = await embeddedTopics(); const dangling: string[] = []; - for (const file of await recipeFiles()) { - const source = await readFile(join(recipeDirectory, file), "utf8"); - for (const [index, line] of source.split("\n").entries()) { - // `taskless agent ` however it is punctuated around, and the - // `npx @taskless/cli agent ` form recipes use when the CLI is - // not assumed to be on PATH. Both are anchored on a package/binary - // name so that prose ("the agent should…") is not read as a citation. + for (const [file, rendered] of await renderedRecipes()) { + for (const [index, line] of rendered.split("\n").entries()) { + // ` agent ` is what the invocation renders to + // under a prod build with no detected launcher, and it is the form + // every recipe now uses. The two older spellings stay matched so a + // citation that regresses to one of them is still checked rather than + // silently skipped. for (const match of line.matchAll( - /(?:taskless|@taskless\/cli) agent ([a-z][a-z-]*)/g + /(?:taskless|@taskless\/cli|) agent ([a-z][a-z-]*)/g )) { const topic = match[1]; if (topic !== undefined && !topics.has(topic)) { @@ -88,3 +125,44 @@ describe("shipped recipes name only commands that exist", () => { expect(mismatched).toEqual([]); }); }); + +describe("recipes defer the CLI invocation to the renderer", () => { + /** + * The regression guard for the normalization. Read over SOURCE, because the + * failure it prevents is an author writing the invocation out by hand — and + * a hardcoded `npx @taskless/cli` is invisible after rendering under a prod + * build, where it is exactly what a correct `%(TASKLESS_CLI)s` would have + * produced. Only the source distinguishes the two. + */ + it("spells no CLI invocation by hand in any recipe source", async () => { + const offenders: string[] = []; + for (const file of await recipeFiles()) { + const source = await readFile(join(recipeDirectory, file), "utf8"); + for (const [index, line] of source.split("\n").entries()) { + if (HARDCODED_INVOCATION.test(line)) { + offenders.push(`${file}:${String(index + 1)}: ${line.trim()}`); + } + } + } + // `taskless check` in a recipe sends a reader to a binary they almost + // certainly do not have on PATH; `npx @taskless/cli check` in a nightly + // sends them to the released package. Both are `%(TASKLESS_CLI)s check`. + expect(offenders).toEqual([]); + }); + + it("renders a real invocation into every recipe that names the CLI", async () => { + const rendered = await renderedRecipes(); + const naming = rendered.filter(([, text]) => + text.includes("") + ); + // Nearly every recipe tells the reader to run something; if this drops to + // a handful, the normalization has been undone rather than improved. + expect(naming.length).toBeGreaterThan(15); + + for (const [file, text] of naming) { + expect(text, `${file} leaked a placeholder`).not.toContain( + "%(TASKLESS_CLI)s" + ); + } + }); +}); From 8117fbc4e6c2f040403a928a24a93066357f7de5 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 12:58:05 -0700 Subject: [PATCH 09/12] test(cli): derive the guard's inputs instead of restating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the cross-reference guard, three findings, one root cause each: - The subcommand alternation was a hand-copied duplicate of the registry in src/index.ts. A command added there and forgotten here would drop out of the guard silently. Moved the names to src/commands/names.ts and had index.ts check its record against them with `satisfies`, so the drift is a type error. - The citation checks anchored on a fixed `taskless|@taskless/cli` spelling, which matches nothing a dev/self build renders — under those targets every check found zero citations and passed without measuring anything. The anchor now derives from `buildInvocation()`, and the dangling-citation check fails when it finds no citation at all. - openspec/specs/cli-agent claimed SKILL.md and tskl.md express the CLI as `%(TASKLESS_CLI)s`. They do not, by design: they carry the canonical `npx @taskless/cli` that `applyCliInvocation` rewrites. Reworded the requirement to describe both mechanisms, and scoped the automated-check sentence to recipe sources, which is all it covers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- openspec/specs/cli-agent/spec.md | 11 ++- packages/cli/src/commands/names.ts | 26 +++++++ packages/cli/src/index.ts | 10 ++- .../cli/test/recipe-cross-references.test.ts | 76 +++++++++++++++---- 4 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/commands/names.ts diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index fbd1de47..05fd648c 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -269,11 +269,14 @@ No embedded recipe SHALL contain the string `taskless help`. Recipes cross-refer ### Requirement: Recipes name the CLI by its full invocation -Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL express the CLI as `%(TASKLESS_CLI)s`, followed by the subcommand and its arguments. This applies to the recipe sources under `packages/cli/src/agent/`, to `skills/taskless/SKILL.md`, and to `commands/tskl/tskl.md`. +Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL name the CLI by the invocation the build resolves for the reader, followed by the subcommand and its arguments — never by a hand-written one. Two mechanisms deliver that, because the content travels by two different paths: -Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers, and SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli`, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. +- Recipe sources under `packages/cli/src/agent/` SHALL express the CLI as `%(TASKLESS_CLI)s`, which the recipe renderer substitutes at render time. +- `skills/taskless/SKILL.md` and `commands/tskl/tskl.md` are not rendered through the recipe renderer, so they SHALL carry the canonical `npx @taskless/cli` string, which `applyCliInvocation` rewrites to the build target's invocation when the content is emitted. -An automated check SHALL fail when a bare `` `taskless ` `` invocation appears in a recipe source, so the normalization cannot silently regress as recipes are edited. +Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers. A recipe source SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli` either, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place — the string is canonical only in the two files above, where it is the rewrite anchor rather than a hardcoding. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. + +An automated check SHALL fail when a bare `` `taskless ` `` invocation, or a hardcoded `npx @taskless/cli`, appears in a recipe source, so the normalization cannot silently regress as recipes are edited. The check SHALL read the subcommand names from the CLI's own registry rather than restating them, so a newly added subcommand is covered without a second edit. It does not extend to `skills/taskless/SKILL.md` or `commands/tskl/tskl.md`, whose rewrite anchor is the very string it flags and which quote invocations as example user utterances. #### Scenario: A recipe instructs the reader to run a subcommand @@ -294,3 +297,5 @@ An automated check SHALL fail when a bare `` `taskless ` `` invocati - **WHEN** the check that recipe cross-references cite only topics that resolve is run - **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder +- **AND** it SHALL anchor on the invocation the running build actually renders, so the check does not pass vacuously under a `nightly`, `dev`, or `self` build whose rendered invocation names neither `taskless` nor `@taskless/cli` +- **AND** it SHALL fail when it finds no cross-reference at all, since an empty result is otherwise indistinguishable from every reference resolving diff --git a/packages/cli/src/commands/names.ts b/packages/cli/src/commands/names.ts new file mode 100644 index 00000000..bf0b0857 --- /dev/null +++ b/packages/cli/src/commands/names.ts @@ -0,0 +1,26 @@ +/** + * Every top-level subcommand the CLI dispatches, as a reader would type it. + * + * This is the single source of truth for the *names*. `src/index.ts` builds its + * `subCommands` record against it with `satisfies`, so a command added or + * removed there without a matching entry here is a type error rather than a + * silent drift — and tests that reason about invocations (see + * `test/recipe-cross-references.test.ts`) read the same list instead of + * restating it. It deliberately imports nothing: a consumer that only needs the + * names must not have to load the whole command tree to get them. + */ +export const SUBCOMMAND_NAMES = [ + "agent", + "auth", + "check", + "detect", + "info", + "init", + "onboard", + "rule", + "test", + "update", + "verify", +] as const; + +export type SubcommandName = (typeof SUBCOMMAND_NAMES)[number]; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1f833e62..17a028d5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { detectCommand } from "./commands/detect"; import { initCommand, updateCommand } from "./commands/init"; import { testCommand, verifyCommand } from "./commands/verify"; import { infoCommand } from "./commands/info"; +import { type SubcommandName } from "./commands/names"; import { onboardCommand } from "./commands/onboard"; import { ruleCommand } from "./commands/rules"; import { @@ -19,6 +20,11 @@ import { DIR_FLAGS, hasHelpFlag, splitRawArguments } from "./util/argv"; import { showResolvedUsage } from "./util/help"; import { CLIError } from "./util/cli-error"; +// `satisfies` against the name list (minus `agent`, which is constructed from +// this record below) is what keeps `SUBCOMMAND_NAMES` honest: adding a command +// here without naming it there — or naming one there that is never registered — +// fails typecheck instead of quietly drifting away from every consumer that +// reads the list. const subCommands = { init: initCommand, update: updateCommand, @@ -30,7 +36,7 @@ const subCommands = { rule: ruleCommand, verify: verifyCommand, test: testCommand, -}; +} satisfies Record, unknown>; const agentCommand = createAgentCommand(subCommands); @@ -61,7 +67,7 @@ const main = defineCommand({ subCommands: { ...subCommands, agent: agentCommand, - }, + } satisfies Record, async run({ rawArgs, cmd }) { // citty always calls the parent's run handler, even after a subcommand. // Only take action when no positional args (i.e. no subcommand) were diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index d7bdd8a7..fc87fcd5 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -2,7 +2,9 @@ import { readdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { SUBCOMMAND_NAMES } from "../src/commands/names"; import { getRecipe } from "../src/prompts/recipes"; +import { buildInvocation } from "../src/util/invocation"; /** * Recipes cite each other by topic name, in prose. Nothing resolves those @@ -24,9 +26,41 @@ import { getRecipe } from "../src/prompts/recipes"; */ const recipeDirectory = resolve(import.meta.dirname, "../src/agent"); -/** The CLI's subcommands, as a recipe would name one. */ -const SUBCOMMANDS = - "agent|auth|check|detect|info|init|onboard|rule|test|update|verify"; +function escapeRegExp(literal: string): string { + return literal.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`); +} + +/** + * The CLI's subcommands, as a recipe would name one — read from the registry + * in `src/commands/names.ts` rather than restated here. A restated list is a + * blind spot with a delay on it: a subcommand added to the CLI but forgotten + * here drops out of the alternation, and the guard below stops flagging a + * hand-written `taskless ` without anything going red. + */ +const SUBCOMMANDS = SUBCOMMAND_NAMES.map((name) => escapeRegExp(name)).join( + "|" +); + +/** + * Every spelling the CLI can carry in RENDERED recipe text, longest first so + * the alternation prefers the most specific. + * + * `` is what a prod build with no detected launcher renders, and + * `buildInvocation()` is whatever THIS build bakes in — `npx @taskless/cli` for + * prod, `npx @taskless/cli-nightly@` for a nightly, and a bare + * `node /index.js` for `dev`/`self`. That last one is why the list is + * derived rather than written out: a fixed `taskless|@taskless/cli` anchor + * matches nothing under `TASKLESS_BUILD_TARGET=self`, so every citation check + * below would find zero citations and pass without checking anything. The two + * bare legacy spellings stay in the list so a citation that regresses to one of + * them is still checked rather than silently skipped. + */ +const CLI_NAMES = [ + escapeRegExp(buildInvocation()), + "", + "@taskless/cli", + "taskless", +].join("|"); /** * A CLI invocation spelled out in a recipe source instead of deferred to @@ -74,7 +108,7 @@ describe("shipped recipes name only commands that exist", () => { const offenders: string[] = []; for (const [file, rendered] of await renderedRecipes()) { for (const [index, line] of rendered.split("\n").entries()) { - if (/(?:taskless|@taskless\/cli|) help\b/.test(line)) { + if (new RegExp(String.raw`(?:${CLI_NAMES}) help\b`).test(line)) { offenders.push(`${file}:${String(index + 1)}: ${line.trim()}`); } } @@ -87,19 +121,19 @@ describe("shipped recipes name only commands that exist", () => { it("cites only topics that resolve to an embedded recipe", async () => { const topics = await embeddedTopics(); const dangling: string[] = []; + const citation = new RegExp( + String.raw`(?:${CLI_NAMES}) agent ([a-z][a-z-]*)`, + "g" + ); + let cited = 0; for (const [file, rendered] of await renderedRecipes()) { for (const [index, line] of rendered.split("\n").entries()) { - // ` agent ` is what the invocation renders to - // under a prod build with no detected launcher, and it is the form - // every recipe now uses. The two older spellings stay matched so a - // citation that regresses to one of them is still checked rather than - // silently skipped. - for (const match of line.matchAll( - /(?:taskless|@taskless\/cli|) agent ([a-z][a-z-]*)/g - )) { + for (const match of line.matchAll(citation)) { const topic = match[1]; - if (topic !== undefined && !topics.has(topic)) { + if (topic === undefined) continue; + cited += 1; + if (!topics.has(topic)) { dangling.push(`${file}:${String(index + 1)} cites '${topic}'`); } } @@ -107,6 +141,13 @@ describe("shipped recipes name only commands that exist", () => { } expect(dangling).toEqual([]); + // An empty `dangling` is the same result whether every citation resolved or + // the pattern matched none of them, and the second reads as a pass. Recipes + // cross-reference each other heavily, so a run that finds no citation at + // all has stopped measuring the thing it reports on. + expect(cited, "found no recipe cross-references to check").toBeGreaterThan( + 10 + ); }); // Every recipe is reachable by the name its filename implies. A header that @@ -152,8 +193,13 @@ describe("recipes defer the CLI invocation to the renderer", () => { it("renders a real invocation into every recipe that names the CLI", async () => { const rendered = await renderedRecipes(); - const naming = rendered.filter(([, text]) => - text.includes("") + // Same build-target reasoning as CLI_NAMES: a prod build with no detected + // launcher renders the `` marker, every other target renders + // its own invocation, and hardcoding the marker would make this assert + // nothing under `dev`/`self`. + const naming = rendered.filter( + ([, text]) => + text.includes("") || text.includes(buildInvocation()) ); // Nearly every recipe tells the reader to run something; if this drops to // a handful, the normalization has been undone rather than improved. From e18676c0d0bf01e8f0a6459306c02b3fa7fa3e40 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 13:05:27 -0700 Subject: [PATCH 10/12] test(cli): exempt the two ci.txt lines where a literal invocation is correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source guard and the `ci.txt` prose it flags were written on separate branches and were each correct in isolation. Combined, the guard fires on two lines that are deliberately literal: - the enumeration of what `%(PACKAGE_MANAGER_DLX)s` expands to, where naming the four complete invocations IS the documentation — substituting the variable would define the placeholder in terms of itself - `pnpm taskless check`, where `taskless` is the binary pnpm resolves from the wired repo's own `node_modules/.bin`. It is a foreign binary, not this CLI's invocation, and `%(TASKLESS_CLI)s` would render it as `pnpm npx @taskless/cli check` Both are prose *about* invocations rather than an instruction to run one, which is the distinction the regex cannot draw on its own. They are exempted by content match with the reason recorded, not by line number, so an edit above them cannot move the exemption onto a different line. An allowlist nobody rechecks is how a guard quietly stops guarding, so a second test fails when an entry no longer matches a line the regex would otherwise flag. Verified by breaking an entry and watching it fail rather than by assuming it passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .../cli/test/recipe-cross-references.test.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index fc87fcd5..5ef0c82e 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -77,6 +77,36 @@ const HARDCODED_INVOCATION = new RegExp( String.raw`)\b)` ); +/** + * The two places a literal invocation is CORRECT, and why. Both are prose + * *about* invocations rather than an instruction to run one, which is the + * distinction the regex cannot draw on its own. + * + * Kept as content matches rather than line numbers so an edit above them does + * not silently move the exemption onto a different line. If the prose changes + * enough that a snippet stops matching, the staleness test below fails — which + * is the intended outcome: a reworded exception deserves to be re-justified, + * not silently carried forward. + */ +const ALLOWED_HARDCODED: { file: string; snippet: string; why: string }[] = [ + { + file: "ci.txt", + snippet: "`npx @taskless/cli`, `pnpm dlx @taskless/cli`,", + why: "Enumerates what %(PACKAGE_MANAGER_DLX)s expands to. Naming the four complete invocations IS the documentation; substituting the variable here would define the placeholder in terms of itself.", + }, + { + file: "ci.txt", + snippet: "`pnpm taskless check`", + why: "That `taskless` is the binary pnpm resolves from the WIRED REPO's node_modules/.bin when @taskless/cli is their dev dependency — a foreign binary, not this CLI's invocation. %(TASKLESS_CLI)s would render `pnpm npx @taskless/cli check`.", + }, +]; + +function isAllowed(file: string, line: string): boolean { + return ALLOWED_HARDCODED.some( + (entry) => entry.file === file && line.includes(entry.snippet) + ); +} + async function recipeFiles(): Promise { const entries = await readdir(recipeDirectory); return entries.filter((entry) => entry.endsWith(".txt")).toSorted(); @@ -180,7 +210,7 @@ describe("recipes defer the CLI invocation to the renderer", () => { for (const file of await recipeFiles()) { const source = await readFile(join(recipeDirectory, file), "utf8"); for (const [index, line] of source.split("\n").entries()) { - if (HARDCODED_INVOCATION.test(line)) { + if (HARDCODED_INVOCATION.test(line) && !isAllowed(file, line)) { offenders.push(`${file}:${String(index + 1)}: ${line.trim()}`); } } @@ -191,6 +221,27 @@ describe("recipes defer the CLI invocation to the renderer", () => { expect(offenders).toEqual([]); }); + // An allowlist nobody rechecks is how a guard quietly stops guarding. Each + // entry must still match a line the regex would otherwise flag: if the prose + // was reworded, or the exception removed, the entry is dead and the reason + // attached to it no longer describes anything. + it("carries no stale entry in the hardcoded-invocation allowlist", async () => { + const stale: string[] = []; + for (const entry of ALLOWED_HARDCODED) { + const source = await readFile(join(recipeDirectory, entry.file), "utf8"); + const stillNeeded = source + .split("\n") + .some( + (line) => + line.includes(entry.snippet) && HARDCODED_INVOCATION.test(line) + ); + if (!stillNeeded) { + stale.push(`${entry.file}: "${entry.snippet}" no longer matches`); + } + } + expect(stale).toEqual([]); + }); + it("renders a real invocation into every recipe that names the CLI", async () => { const rendered = await renderedRecipes(); // Same build-target reasoning as CLI_NAMES: a prod build with no detected From 99b97e49f62e503577535c562369b2e6e15dedab Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 14:16:45 -0700 Subject: [PATCH 11/12] fix(cli): clear the four deferred review items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Unreachable fallback in `getCliPrefix`.** The branch calling `buildInvocation()` could never execute: it was reached only when `detectCliInvocation` returned `undefined`, which happens only after `pinnedSpecifier()` was already confirmed non-`undefined` one call up. `getCliPrefix` now asks `pinnedSpecifier()` once and defaults the launcher with `?? "npx"`, so the display default is stated where it applies instead of being reconstructed behind a condition that cannot hold. **`taskless test` false positive.** `test` is a subcommand and an ordinary English word, so "the taskless test suite" would have failed CI on a sentence nobody should reword. An invocation is always code, so the guard now scans fenced blocks and inline code spans rather than whole lines. Requiring a backtick would have been the tempting simplification and the wrong one — a raw command inside a fence carries none, and that is the likelier way to write one by hand. Both halves are pinned by tests. **Table padding.** Prettier cannot do this — it infers no parser for `.txt`, and lint-staged covers only `md|json|graphql` and JS/TS — so the tables were re-padded mechanically. Every non-whitespace change is a separator row whose dash count follows its column width. **Import grouping.** `sprintf-js` and `vitest` are both external packages and now share one group, per the styleguide. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- packages/cli/src/agent/auth.txt | 10 +-- packages/cli/src/agent/check.txt | 6 +- packages/cli/src/agent/ci.txt | 54 ++++++------ packages/cli/src/agent/create-remote-rule.txt | 16 ++-- .../cli/src/agent/create-runtime-rule.txt | 8 +- packages/cli/src/agent/create-sg-rule.txt | 10 +-- packages/cli/src/agent/create-vale-rule.txt | 86 +++++++++---------- packages/cli/src/agent/delete-rule.txt | 6 +- packages/cli/src/agent/detect.txt | 6 +- .../cli/src/agent/improve-rule.anonymous.txt | 10 +-- packages/cli/src/agent/improve-rule.txt | 18 ++-- packages/cli/src/agent/info.txt | 6 +- packages/cli/src/agent/onboard.txt | 6 +- packages/cli/src/agent/route.txt | 34 ++++---- packages/cli/src/agent/rule-meta.txt | 8 +- packages/cli/src/agent/rule.txt | 14 +-- packages/cli/src/agent/verify-rule.txt | 20 ++--- packages/cli/src/util/package-manager.ts | 10 +-- packages/cli/test/prompts.test.ts | 2 +- .../cli/test/recipe-cross-references.test.ts | 78 ++++++++++++++--- 20 files changed, 231 insertions(+), 177 deletions(-) diff --git a/packages/cli/src/agent/auth.txt b/packages/cli/src/agent/auth.txt index 58db835c..cd057854 100644 --- a/packages/cli/src/agent/auth.txt +++ b/packages/cli/src/agent/auth.txt @@ -65,11 +65,11 @@ emitted. The status path (`%(TASKLESS_CLI)s auth` with no subcommand) accepts `--json` for forward-compat but currently has no error paths to report. -| code | meaning | fix | -|------------------|-------------------------------------------------------------------------------------------|------------------------------| -| `INVALID_INPUT` | `--anonymous` passed to `auth login` (rejected: auth commands cannot be anonymous) | Don't pass `--anonymous` | -| `NETWORK_ERROR` | Device flow / token endpoint unreachable, or the device code expired before approval | Check connectivity; retry | -| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Re-run `%(TASKLESS_CLI)s auth login` | +| code | meaning | fix | +|-----------------|--------------------------------------------------------------------------------------|--------------------------------------| +| `INVALID_INPUT` | `--anonymous` passed to `auth login` (rejected: auth commands cannot be anonymous) | Don't pass `--anonymous` | +| `NETWORK_ERROR` | Device flow / token endpoint unreachable, or the device code expired before approval | Check connectivity; retry | +| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Re-run `%(TASKLESS_CLI)s auth login` | ## See Also diff --git a/packages/cli/src/agent/check.txt b/packages/cli/src/agent/check.txt index 43e715f6..d308cbfc 100644 --- a/packages/cli/src/agent/check.txt +++ b/packages/cli/src/agent/check.txt @@ -119,9 +119,9 @@ authoritative allow-list is the server's; the CI backstop When `--json` is set, failures emit `{ ok: false, code, message }`: -| code | meaning | fix | -|----------------|------------------------------------|----------------------------------| -| `SCAN_FAILED` | ast-grep scan errored | Report; check rule YAML validity | +| code | meaning | fix | +|---------------|-----------------------|----------------------------------| +| `SCAN_FAILED` | ast-grep scan errored | Report; check rule YAML validity | ## See Also diff --git a/packages/cli/src/agent/ci.txt b/packages/cli/src/agent/ci.txt index 7ad7d784..b27afd98 100644 --- a/packages/cli/src/agent/ci.txt +++ b/packages/cli/src/agent/ci.txt @@ -25,17 +25,17 @@ you recognize one not on the list, apply the same patterns. Scan the repo root for CI config files. Hints (not exhaustive): -| File / directory | CI system | -|-------------------------------------------|---------------------| -| `.github/workflows/*.yml` | GitHub Actions | -| `.gitlab-ci.yml` | GitLab CI | -| `.circleci/config.yml` | CircleCI | -| `Jenkinsfile` | Jenkins | -| `azure-pipelines.yml` | Azure Pipelines | -| `bitbucket-pipelines.yml` | Bitbucket Pipelines | -| `.buildkite/` | Buildkite | -| `.drone.yml` | Drone | -| `.travis.yml` | Travis CI | +| File / directory | CI system | +|---------------------------|---------------------| +| `.github/workflows/*.yml` | GitHub Actions | +| `.gitlab-ci.yml` | GitLab CI | +| `.circleci/config.yml` | CircleCI | +| `Jenkinsfile` | Jenkins | +| `azure-pipelines.yml` | Azure Pipelines | +| `bitbucket-pipelines.yml` | Bitbucket Pipelines | +| `.buildkite/` | Buildkite | +| `.drone.yml` | Drone | +| `.travis.yml` | Travis CI | Sum up what you found and confirm with the user. If zero match, ask which CI they use. If multiple match, ask which should run Taskless. @@ -47,14 +47,14 @@ which CI they use. If multiple match, ask which should run Taskless. - **Diff scan** — `%(TASKLESS_CLI)s check $(git diff --name-only ...)`. Faster for PR builds. Per-CI diff target var: - | CI | Target branch variable | - |---------------------|----------------------------------------------| - | GitHub Actions | `github.base_ref` | - | GitLab CI | `CI_MERGE_REQUEST_TARGET_BRANCH_NAME` | - | CircleCI | `CIRCLE_BRANCH` (fetch main and diff against)| - | Jenkins | `env.CHANGE_TARGET` | - | Azure Pipelines | `System.PullRequest.TargetBranch` | - | Bitbucket Pipelines | `BITBUCKET_PR_DESTINATION_BRANCH` | +| CI | Target branch variable | +|---------------------|-----------------------------------------------| +| GitHub Actions | `github.base_ref` | +| GitLab CI | `CI_MERGE_REQUEST_TARGET_BRANCH_NAME` | +| CircleCI | `CIRCLE_BRANCH` (fetch main and diff against) | +| Jenkins | `env.CHANGE_TARGET` | +| Azure Pipelines | `System.PullRequest.TargetBranch` | +| Bitbucket Pipelines | `BITBUCKET_PR_DESTINATION_BRANCH` | `%(TASKLESS_CLI)s check` silently filters paths that don't exist, so raw `git diff --name-only` output can pipe in directly. @@ -81,15 +81,15 @@ Rules: Canonical paths: -| CI | File path | -|---------------------|----------------------------------------------| -| GitHub Actions | `.github/workflows/taskless.yml` (standalone, no include needed) | -| GitLab CI | `.taskless/ci/gitlab.yml` (user adds `include`) | -| CircleCI | `.taskless/ci/circleci-job.yml` (no include — user copies job) | -| Jenkins | `.taskless/ci/taskless.Jenkinsfile` (user `load()`s) | +| CI | File path | +|---------------------|---------------------------------------------------------------------| +| GitHub Actions | `.github/workflows/taskless.yml` (standalone, no include needed) | +| GitLab CI | `.taskless/ci/gitlab.yml` (user adds `include`) | +| CircleCI | `.taskless/ci/circleci-job.yml` (no include — user copies job) | +| Jenkins | `.taskless/ci/taskless.Jenkinsfile` (user `load()`s) | | Azure Pipelines | `.taskless/ci/azure-taskless.yml` (user references via `template:`) | -| Bitbucket Pipelines | `.taskless/ci/bitbucket-pipelines.yml` (user merges manually) | -| Other | `.taskless/ci/.` + clear wiring instructions | +| Bitbucket Pipelines | `.taskless/ci/bitbucket-pipelines.yml` (user merges manually) | +| Other | `.taskless/ci/.` + clear wiring instructions | ### 5. GitHub Actions reference template diff --git a/packages/cli/src/agent/create-remote-rule.txt b/packages/cli/src/agent/create-remote-rule.txt index a1178583..dbd0ad58 100644 --- a/packages/cli/src/agent/create-remote-rule.txt +++ b/packages/cli/src/agent/create-remote-rule.txt @@ -124,14 +124,14 @@ Multi-line code goes in one string with literal newlines. With `--json`, failures emit `{ ok: false, code, message }`: -| code | meaning | fix | -|--------------------------|------------------------------------|----------------------------------------------| -| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | -| `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | -| `INVALID_INPUT` | `--from` JSON failed validation | re-read the input schema, fix, retry | -| `NETWORK_ERROR` | submit/poll failed | report and suggest retry | -| `RULE_GENERATION_FAILED` | the service failed to generate | report the message; suggest enriching prompt | -| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | +| code | meaning | fix | +|--------------------------|---------------------------------|----------------------------------------------| +| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | +| `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | +| `INVALID_INPUT` | `--from` JSON failed validation | re-read the input schema, fix, retry | +| `NETWORK_ERROR` | submit/poll failed | report and suggest retry | +| `RULE_GENERATION_FAILED` | the service failed to generate | report the message; suggest enriching prompt | +| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | ## See Also diff --git a/packages/cli/src/agent/create-runtime-rule.txt b/packages/cli/src/agent/create-runtime-rule.txt index 490c4735..ab89b817 100644 --- a/packages/cli/src/agent/create-runtime-rule.txt +++ b/packages/cli/src/agent/create-runtime-rule.txt @@ -27,10 +27,10 @@ tiers are not, and what the user has to do before one can run. A rule directory under `.taskless/rules/runtime//` holding two kinds of file: -| Path | Role | -|-------------------|------------------------------------------------------------------| -| `captures/*.yml` | ast-grep capture rules that narrow which files the check looks at | -| `check.ts` | a module whose default export receives those matches and returns findings | +| Path | Role | +|------------------|---------------------------------------------------------------------------| +| `captures/*.yml` | ast-grep capture rules that narrow which files the check looks at | +| `check.ts` | a module whose default export receives those matches and returns findings | `verify` fails a runtime rule with no capture rule in `captures/`, because `check.ts` would then never be invoked. diff --git a/packages/cli/src/agent/create-sg-rule.txt b/packages/cli/src/agent/create-sg-rule.txt index 2ae50c62..bb91f094 100644 --- a/packages/cli/src/agent/create-sg-rule.txt +++ b/packages/cli/src/agent/create-sg-rule.txt @@ -102,11 +102,11 @@ whole rule. - `ok: true` → go to step 7. - `ok: false` → read `errors` and fix. Repeat up to 3 times. - | what `errors` says | fix | - |-----------------------------------------|------------------------------------------------| - | the YAML doesn't match the schema | check field types against the upstream schema | - | a Taskless-required field is missing | add `id`/`language`/`severity`/`message`/`rule`| - | a case didn't behave as expected | fix the rule pattern OR the test case | +| what `errors` says | fix | +|--------------------------------------|-------------------------------------------------| +| the YAML doesn't match the schema | check field types against the upstream schema | +| a Taskless-required field is missing | add `id`/`language`/`severity`/`message`/`rule` | +| a case didn't behave as expected | fix the rule pattern OR the test case | A `regex` without an accompanying `kind` fails verification — the two always travel together. diff --git a/packages/cli/src/agent/create-vale-rule.txt b/packages/cli/src/agent/create-vale-rule.txt index 2e731071..4522e83c 100644 --- a/packages/cli/src/agent/create-vale-rule.txt +++ b/packages/cli/src/agent/create-vale-rule.txt @@ -62,19 +62,19 @@ it. built by extending one of its eleven checks, and the sentence tells you which: - | If the rule is about… | extends | - |-------------------------------------------------------|------------------| - | words or phrases that should not appear | `existence` | - | preferring one term over another — **including the correct spelling of a product name** | `substitution` | - | the case of a whole heading or sentence | `capitalization` | - | how many times something may appear | `occurrence` | - | a word repeated back to back | `repetition` | - | picking one of two acceptable spellings, consistently | `consistency` | - | "if X appears, Y must also appear" | `conditional` | - | readability or length thresholds | `metric` | - | a misspelling, against a dictionary | `spelling` | - | phrases that must appear in a fixed order | `sequence` | - | anything the above cannot express (Tengo script) | `script` | +| If the rule is about… | extends | +|-----------------------------------------------------------------------------------------|------------------| +| words or phrases that should not appear | `existence` | +| preferring one term over another — **including the correct spelling of a product name** | `substitution` | +| the case of a whole heading or sentence | `capitalization` | +| how many times something may appear | `occurrence` | +| a word repeated back to back | `repetition` | +| picking one of two acceptable spellings, consistently | `consistency` | +| "if X appears, Y must also appear" | `conditional` | +| readability or length thresholds | `metric` | +| a misspelling, against a dictionary | `spelling` | +| phrases that must appear in a fixed order | `sequence` | +| anything the above cannot express (Tengo script) | `script` | **`capitalization` is about a whole scope, not a word.** It asks whether an entire heading or sentence matches a case pattern. It @@ -106,13 +106,13 @@ it. Every rule carries: - | Field | Required | Notes | - |-----------|----------|---------------------------------------------------------| - | `extends` | yes | one of the eleven above | - | `message` | yes | shown to the user; see the `%%s` table below | - | `level` | no | `suggestion` (default), `warning`, or `error` | - | `scope` | no | narrow to part of a document — see below | - | `link` | no | a URL the reader can follow for the reasoning | +| Field | Required | Notes | +|-----------|----------|-----------------------------------------------| +| `extends` | yes | one of the eleven above | +| `message` | yes | shown to the user; see the `%%s` table below | +| `level` | no | `suggestion` (default), `warning`, or `error` | +| `scope` | no | narrow to part of a document — see below | +| `link` | no | a URL the reader can follow for the reasoning | **`scope` decides where the rule looks**, so getting it wrong is a silent over- or under-fire rather than an error. The useful values for @@ -124,27 +124,27 @@ it. **Then the fields the extension point adds** — this is where the rule actually lives, and each check reads only its own: - | extends | its fields | - |------------------|-------------------------------------------------------------------| - | `existence` | `tokens` (a list) or `raw`; `ignorecase`, `nonword`, `exceptions`, `append` | - | `substitution` | `swap` (a map of observed → expected); `ignorecase`, `nonword`, `exceptions` | - | `capitalization` | `match`; `exceptions`, `style` (with `$title`), `threshold`, `indicators`, `prefix` | - | `occurrence` | `token`, `max` and/or `min` | - | `repetition` | `tokens`; `alpha`, `ignorecase` | - | `consistency` | `either` (a map of the two acceptable forms) | - | `conditional` | `first`, `second`; `exceptions` | - | `metric` | `formula`, `condition` | +| extends | its fields | +|------------------|-------------------------------------------------------------------------------------| +| `existence` | `tokens` (a list) or `raw`; `ignorecase`, `nonword`, `exceptions`, `append` | +| `substitution` | `swap` (a map of observed → expected); `ignorecase`, `nonword`, `exceptions` | +| `capitalization` | `match`; `exceptions`, `style` (with `$title`), `threshold`, `indicators`, `prefix` | +| `occurrence` | `token`, `max` and/or `min` | +| `repetition` | `tokens`; `alpha`, `ignorecase` | +| `consistency` | `either` (a map of the two acceptable forms) | +| `conditional` | `first`, `second`; `exceptions` | +| `metric` | `formula`, `condition` | **What `%%s` fills with depends on the extension point.** Getting this wrong is the one mistake in this recipe that passes every check below — the rule fires, the fixtures are green, and only a human reading the message sees that it is nonsense. - | extends | `%%s` count | fills with, left to right | - |------------------|------------|---------------------------| - | `existence` | one | the matched text | - | `substitution` | **two** | the **replacement**, then the matched text | - | `capitalization` | one | the scope that failed (the whole heading or sentence) | +| extends | `%%s` count | fills with, left to right | +|------------------|-------------|-------------------------------------------------------| +| `existence` | one | the matched text | +| `substitution` | **two** | the **replacement**, then the matched text | +| `capitalization` | one | the scope that failed (the whole heading or sentence) | Measured: a `substitution` message with a single `%%s` interpolates the *replacement*, not the match, so `"Use GitHub not %%s"` against the text @@ -196,14 +196,14 @@ it. — proper nouns included.** It is not "sentence case allowing proper nouns". Measured with `exceptions: [Taskless, API]` on headings: - | Heading | Result | - |------------------------------------|--------| - | `Getting started with the API` | quiet | - | `Getting started with APIs` | quiet — an exception covers its plural | - | `Taskless and the API` | quiet — an exception may lead the scope | - | `Getting started with Kubernetes` | **fires** — a proper noun you did not list | - | `getting started lowercase` | **fires** — the first word must be capitalized | - | `Getting Started With Title Case` | **fires** | +| Heading | Result | +|-----------------------------------|------------------------------------------------| +| `Getting started with the API` | quiet | +| `Getting started with APIs` | quiet — an exception covers its plural | +| `Taskless and the API` | quiet — an exception may lead the scope | +| `Getting started with Kubernetes` | **fires** — a proper noun you did not list | +| `getting started lowercase` | **fires** — the first word must be capitalized | +| `Getting Started With Title Case` | **fires** | So `exceptions` is not decoration: every proper noun, product name and acronym the docs use has to be listed, or the rule flags correct diff --git a/packages/cli/src/agent/delete-rule.txt b/packages/cli/src/agent/delete-rule.txt index 7cae2a7e..fcd13300 100644 --- a/packages/cli/src/agent/delete-rule.txt +++ b/packages/cli/src/agent/delete-rule.txt @@ -39,9 +39,9 @@ rule is deleted by removing its directory by hand. When `--json` is set, failures emit `{ ok: false, code, message }` on stdout; on success the command exits 0 silently (no envelope). -| code | meaning | fix | -|------------------|--------------------------|--------------------------------------| -| `RULE_NOT_FOUND` | No `rules/sg//` | Confirm the ID; list rules first | +| code | meaning | fix | +|------------------|---------------------|----------------------------------| +| `RULE_NOT_FOUND` | No `rules/sg//` | Confirm the ID; list rules first | The rule ID is required as a positional argument; citty rejects a missing ID before the command body runs, so `INVALID_INPUT` is not diff --git a/packages/cli/src/agent/detect.txt b/packages/cli/src/agent/detect.txt index 8209dd45..cce03a13 100644 --- a/packages/cli/src/agent/detect.txt +++ b/packages/cli/src/agent/detect.txt @@ -53,9 +53,9 @@ routing flow reads `detect` to decide where a new rule should live. When `--json` is set, failures emit `{ ok: false, code, message }`: -| code | meaning | fix | -|-------------------|----------------------------------|---------------------------| -| `INTERNAL_ERROR` | Internal schema validation | Report; likely a CLI bug | +| code | meaning | fix | +|------------------|----------------------------|--------------------------| +| `INTERNAL_ERROR` | Internal schema validation | Report; likely a CLI bug | ## See Also diff --git a/packages/cli/src/agent/improve-rule.anonymous.txt b/packages/cli/src/agent/improve-rule.anonymous.txt index 6dffc8fd..5499cc81 100644 --- a/packages/cli/src/agent/improve-rule.anonymous.txt +++ b/packages/cli/src/agent/improve-rule.anonymous.txt @@ -76,11 +76,11 @@ validate with `verify` and `test` in a feedback loop. The verify primitive returns structured errors per layer: -| layer | what failure means | fix | -|----------------|----------------------------------------------|-------------------------------------------| -| `schema` | YAML doesn't match ast-grep schema | Fix rule structure | -| `requirements` | Missing Taskless-required field | Add `id`/`language`/`severity`/etc. | -| `tests` | A test case behaved unexpectedly | Fix the rule pattern OR the test case | +| layer | what failure means | fix | +|----------------|------------------------------------|---------------------------------------| +| `schema` | YAML doesn't match ast-grep schema | Fix rule structure | +| `requirements` | Missing Taskless-required field | Add `id`/`language`/`severity`/etc. | +| `tests` | A test case behaved unexpectedly | Fix the rule pattern OR the test case | ## See Also diff --git a/packages/cli/src/agent/improve-rule.txt b/packages/cli/src/agent/improve-rule.txt index 320f867a..dba5d1d4 100644 --- a/packages/cli/src/agent/improve-rule.txt +++ b/packages/cli/src/agent/improve-rule.txt @@ -87,15 +87,15 @@ The `--from` JSON file conforms to: When `--json` is set, failures emit `{ ok: false, code, message }`: -| code | meaning | fix | -|--------------------------|----------------------------------------|----------------------------------------------| -| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | -| `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | -| `INVALID_INPUT` | `--from` JSON failed validation | re-read input schema, fix, retry | -| `RULE_NOT_FOUND` | metadata missing for the given rule | use anonymous variant or recreate via create | -| `NETWORK_ERROR` | API submit/poll failed | report and suggest retry | -| `RULE_GENERATION_FAILED` | API returned a generation failure | report; suggest enriching guidance/references | -| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | +| code | meaning | fix | +|--------------------------|-------------------------------------|-----------------------------------------------| +| `AUTH_REQUIRED` | not logged in | fetch `%(TASKLESS_CLI)s agent auth` | +| `NO_GITHUB_REMOTE` | no GitHub origin remote | tell the user; we cannot proceed | +| `INVALID_INPUT` | `--from` JSON failed validation | re-read input schema, fix, retry | +| `RULE_NOT_FOUND` | metadata missing for the given rule | use anonymous variant or recreate via create | +| `NETWORK_ERROR` | API submit/poll failed | report and suggest retry | +| `RULE_GENERATION_FAILED` | API returned a generation failure | report; suggest enriching guidance/references | +| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | ## See Also diff --git a/packages/cli/src/agent/info.txt b/packages/cli/src/agent/info.txt index a611e84f..a4716b18 100644 --- a/packages/cli/src/agent/info.txt +++ b/packages/cli/src/agent/info.txt @@ -53,9 +53,9 @@ which version of the CLI/skills the agent is talking to. When `--json` is set, failures emit `{ ok: false, code, message }`: -| code | meaning | fix | -|-------------------|----------------------------------|---------------------------| -| `INTERNAL_ERROR` | Internal schema validation | Report; likely a CLI bug | +| code | meaning | fix | +|------------------|----------------------------|--------------------------| +| `INTERNAL_ERROR` | Internal schema validation | Report; likely a CLI bug | (Network errors during the auth probe are silently swallowed — `info` falls back to reporting `loggedIn: false` rather than diff --git a/packages/cli/src/agent/onboard.txt b/packages/cli/src/agent/onboard.txt index 00fb756a..7c176d2f 100644 --- a/packages/cli/src/agent/onboard.txt +++ b/packages/cli/src/agent/onboard.txt @@ -145,9 +145,9 @@ rules as a bullet list the user can choose to materialize via the ## Errors -| code | meaning | fix | -|---------------------|-----------------------------------------------|--------------------------------------| -| `ALREADY_ONBOARDED` | `install.onboarded` is true and no `--force` | suggest `--force` or skip onboarding | +| code | meaning | fix | +|---------------------|----------------------------------------------|--------------------------------------| +| `ALREADY_ONBOARDED` | `install.onboarded` is true and no `--force` | suggest `--force` or skip onboarding | ## See Also diff --git a/packages/cli/src/agent/route.txt b/packages/cli/src/agent/route.txt index 1e0122ed..2af9e39a 100644 --- a/packages/cli/src/agent/route.txt +++ b/packages/cli/src/agent/route.txt @@ -63,12 +63,12 @@ answered together. and only here — the destination recipes describe their own scope and deliberately do not restate this table. - | The rule is decided by… | Destination | Login | - |-----------------------------------------------------------------|----------------------|-------| - | a tool the repo already runs, in that tool's dialect | `create-legacy-rule` | no | - | **one file's syntax tree** — a call, an import, a JSX attribute, a type annotation | `create-sg-rule` | no | - | **a document's words** — docs, README, comments, commit bodies | `create-vale-rule` | no | - | **more than one file, or something outside the files** — the repo graph, git metadata, build output, a resolved config chain | `create-runtime-rule` / `create-remote-rule` | see step 5 | +| The rule is decided by… | Destination | Login | +|------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------|------------| +| a tool the repo already runs, in that tool's dialect | `create-legacy-rule` | no | +| **one file's syntax tree** — a call, an import, a JSX attribute, a type annotation | `create-sg-rule` | no | +| **a document's words** — docs, README, comments, commit bodies | `create-vale-rule` | no | +| **more than one file, or something outside the files** — the repo graph, git metadata, build output, a resolved config chain | `create-runtime-rule` / `create-remote-rule` | see step 5 | Sharpening the three engine rows, because most wrong answers are one of these: @@ -92,17 +92,17 @@ answered together. Worked examples: - | Rule intent | Evidence needed | Destination | - |-----------------------------------------------------------|-------------------------------------------|----------------------| - | No `eval(...)` anywhere | one file's call expressions | `create-sg-rule` | - | `useEffect` deps must include what the body reads | one file's tree, correlated within it | `create-sg-rule` | - | Don't write "simply" or "just" in docs | a document's words | `create-vale-rule` | - | Comments must not say "obviously" | a document's words (comments are prose) | `create-vale-rule` | - | Headings use sentence case | a document's markup | `create-vale-rule` | - | Exported symbols must be used somewhere in the repo | every file, correlated | runtime | - | Product name spelled the same across all docs | many documents, compared | runtime | - | Files changed in the last release need a changelog entry | git metadata, not file contents | runtime | - | Imports must resolve through the tsconfig path aliases | config chain resolution, outside the file | runtime | +| Rule intent | Evidence needed | Destination | +|----------------------------------------------------------|-------------------------------------------|--------------------| +| No `eval(...)` anywhere | one file's call expressions | `create-sg-rule` | +| `useEffect` deps must include what the body reads | one file's tree, correlated within it | `create-sg-rule` | +| Don't write "simply" or "just" in docs | a document's words | `create-vale-rule` | +| Comments must not say "obviously" | a document's words (comments are prose) | `create-vale-rule` | +| Headings use sentence case | a document's markup | `create-vale-rule` | +| Exported symbols must be used somewhere in the repo | every file, correlated | runtime | +| Product name spelled the same across all docs | many documents, compared | runtime | +| Files changed in the last release need a changelog entry | git metadata, not file contents | runtime | +| Imports must resolve through the tsconfig path aliases | config chain resolution, outside the file | runtime | 5. **Split the runtime row on login state.** Runtime rules execute code, so they run only against a server-verified signature: diff --git a/packages/cli/src/agent/rule-meta.txt b/packages/cli/src/agent/rule-meta.txt index 19355ab9..1789c98f 100644 --- a/packages/cli/src/agent/rule-meta.txt +++ b/packages/cli/src/agent/rule-meta.txt @@ -20,10 +20,10 @@ version, etc. ## Errors -| code | meaning | fix | -|------------------|--------------------------------------|------------------------------------| -| `RULE_NOT_FOUND` | metadata sidecar missing | Use anonymous improve flow instead | -| `INVALID_INPUT` | metadata file malformed | File is corrupted; re-create rule | +| code | meaning | fix | +|------------------|--------------------------|------------------------------------| +| `RULE_NOT_FOUND` | metadata sidecar missing | Use anonymous improve flow instead | +| `INVALID_INPUT` | metadata file malformed | File is corrupted; re-create rule | ## See Also diff --git a/packages/cli/src/agent/rule.txt b/packages/cli/src/agent/rule.txt index dbbd35f3..aea8fe42 100644 --- a/packages/cli/src/agent/rule.txt +++ b/packages/cli/src/agent/rule.txt @@ -5,13 +5,13 @@ Umbrella for rule operations. Fetch the topic for the action you want. ## Topics -| Action | Recipe | -|---------------------|-----------------------------------------------| -| Create a rule | `%(TASKLESS_CLI)s agent route` | -| Improve a rule | `%(TASKLESS_CLI)s agent improve-rule` | -| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | -| Verify a rule | `%(TASKLESS_CLI)s agent verify-rule` (agent-internal) | -| Read rule metadata | `%(TASKLESS_CLI)s agent rule-meta` (agent-internal) | +| Action | Recipe | +|--------------------|-------------------------------------------------------| +| Create a rule | `%(TASKLESS_CLI)s agent route` | +| Improve a rule | `%(TASKLESS_CLI)s agent improve-rule` | +| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | +| Verify a rule | `%(TASKLESS_CLI)s agent verify-rule` (agent-internal) | +| Read rule metadata | `%(TASKLESS_CLI)s agent rule-meta` (agent-internal) | `route` is the entry point for authoring: it reads the request and names the `create-*-rule` topic that fits, so you do not pick an engine diff --git a/packages/cli/src/agent/verify-rule.txt b/packages/cli/src/agent/verify-rule.txt index c800b756..06418f19 100644 --- a/packages/cli/src/agent/verify-rule.txt +++ b/packages/cli/src/agent/verify-rule.txt @@ -31,11 +31,11 @@ and would otherwise bury the reason the rule could never have run. What each engine is checked for: -| engine | `verify` checks | `test` runs | -|-----------|----------------------------------------------------------------------------|-----------------------------------| -| `sg` | ast-grep schema, plus `id`/`language`/`severity`/`message`/`rule`, and `regex` accompanied by `kind` | the `valid`/`invalid` cases in `.tests/` | -| `vale` | style parses, `extends` and `message` present, `level` in vocabulary, and the rule's `.vale.ini` enables `.` under a matcher | the `.tests/pass/` and `.tests/fail/` buckets | -| `runtime` | `check.ts` present, at least one capture rule in `captures/` | reported as not run: runtime tests need the server harness | +| engine | `verify` checks | `test` runs | +|-----------|--------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------| +| `sg` | ast-grep schema, plus `id`/`language`/`severity`/`message`/`rule`, and `regex` accompanied by `kind` | the `valid`/`invalid` cases in `.tests/` | +| `vale` | style parses, `extends` and `message` present, `level` in vocabulary, and the rule's `.vale.ini` enables `.` under a matcher | the `.tests/pass/` and `.tests/fail/` buckets | +| `runtime` | `check.ts` present, at least one capture rule in `captures/` | reported as not run: runtime tests need the server harness | ## What a path means @@ -43,11 +43,11 @@ The path is a location under `.taskless/rules/`, and the `` segment is what decides the engine. Nothing parses a rule file to work out who owns it, so the same id under two engines is never ambiguous. -| Path | Scope | -|-----------------------------------|-----------------------------| -| `.taskless/rules/vale/no-simply` | that one rule | -| `.taskless/rules/vale` | every Vale rule | -| `.taskless/rules` | every rule (also the default when you pass no path) | +| Path | Scope | +|----------------------------------|-----------------------------------------------------| +| `.taskless/rules/vale/no-simply` | that one rule | +| `.taskless/rules/vale` | every Vale rule | +| `.taskless/rules` | every rule (also the default when you pass no path) | A path outside `.taskless/rules/` is an error naming the path. So is a path inside it that holds no rule. diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 4ecfa029..2fe06fe7 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -171,10 +171,10 @@ export function detectCliInvocation( * marker would be useless here and a guessed launcher is useless in a recipe. */ export function getCliPrefix(): string { - const detected = detectCliInvocation(processLauncherContext()); - if (detected !== undefined) return detected; const specifier = pinnedSpecifier(); - return specifier === undefined - ? buildInvocation() - : `${LAUNCHER_COMMANDS.npx} ${specifier}`; + // A path-form build has no launcher to choose, so there is nothing to + // default to — the same reason `detectCliInvocation` returns early on it. + if (specifier === undefined) return buildInvocation(); + const launcher = detectLauncher(processLauncherContext()); + return `${LAUNCHER_COMMANDS[launcher ?? "npx"]} ${specifier}`; } diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index dd11e8f3..6a056753 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -3,9 +3,9 @@ import { readFile, readdir } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; import { sprintf } from "sprintf-js"; +import { describe, expect, it } from "vitest"; import { PROMPTS, diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index 5ef0c82e..a4e5a497 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -77,13 +77,45 @@ const HARDCODED_INVOCATION = new RegExp( String.raw`)\b)` ); +/** + * The regex alone cannot tell an invocation from prose that happens to read + * like one: `test` is a subcommand AND an ordinary English word, so "the + * taskless test suite" would be reported as a hardcoded invocation and fail + * CI on a sentence nobody should have to reword. + * + * An invocation is always *code* — a fenced block or an inline code span — so + * that is where the guard looks. Checking only inline spans would be the + * tempting simplification and the wrong one: a raw command on its own line + * inside a fence carries no backticks, and that is the likelier way to write + * one by hand. + */ +function codeFragments(source: string): { line: number; text: string }[] { + const fragments: { line: number; text: string }[] = []; + let inFence = false; + for (const [index, line] of source.split("\n").entries()) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) { + fragments.push({ line: index + 1, text: line }); + continue; + } + for (const span of line.matchAll(/`([^`]+)`/g)) { + fragments.push({ line: index + 1, text: span[1] ?? "" }); + } + } + return fragments; +} + /** * The two places a literal invocation is CORRECT, and why. Both are prose * *about* invocations rather than an instruction to run one, which is the * distinction the regex cannot draw on its own. * * Kept as content matches rather than line numbers so an edit above them does - * not silently move the exemption onto a different line. If the prose changes + * not silently move the exemption onto a different line. The snippets match + * the code fragment's text, which carries no surrounding backticks. If the prose changes * enough that a snippet stops matching, the staleness test below fails — which * is the intended outcome: a reworded exception deserves to be re-justified, * not silently carried forward. @@ -91,12 +123,12 @@ const HARDCODED_INVOCATION = new RegExp( const ALLOWED_HARDCODED: { file: string; snippet: string; why: string }[] = [ { file: "ci.txt", - snippet: "`npx @taskless/cli`, `pnpm dlx @taskless/cli`,", + snippet: "npx @taskless/cli", why: "Enumerates what %(PACKAGE_MANAGER_DLX)s expands to. Naming the four complete invocations IS the documentation; substituting the variable here would define the placeholder in terms of itself.", }, { file: "ci.txt", - snippet: "`pnpm taskless check`", + snippet: "pnpm taskless check", why: "That `taskless` is the binary pnpm resolves from the WIRED REPO's node_modules/.bin when @taskless/cli is their dev dependency — a foreign binary, not this CLI's invocation. %(TASKLESS_CLI)s would render `pnpm npx @taskless/cli check`.", }, ]; @@ -209,9 +241,9 @@ describe("recipes defer the CLI invocation to the renderer", () => { const offenders: string[] = []; for (const file of await recipeFiles()) { const source = await readFile(join(recipeDirectory, file), "utf8"); - for (const [index, line] of source.split("\n").entries()) { - if (HARDCODED_INVOCATION.test(line) && !isAllowed(file, line)) { - offenders.push(`${file}:${String(index + 1)}: ${line.trim()}`); + for (const { line, text } of codeFragments(source)) { + if (HARDCODED_INVOCATION.test(text) && !isAllowed(file, text)) { + offenders.push(`${file}:${String(line)}: ${text.trim()}`); } } } @@ -229,12 +261,10 @@ describe("recipes defer the CLI invocation to the renderer", () => { const stale: string[] = []; for (const entry of ALLOWED_HARDCODED) { const source = await readFile(join(recipeDirectory, entry.file), "utf8"); - const stillNeeded = source - .split("\n") - .some( - (line) => - line.includes(entry.snippet) && HARDCODED_INVOCATION.test(line) - ); + const stillNeeded = codeFragments(source).some( + ({ text }) => + text.includes(entry.snippet) && HARDCODED_INVOCATION.test(text) + ); if (!stillNeeded) { stale.push(`${entry.file}: "${entry.snippet}" no longer matches`); } @@ -242,6 +272,30 @@ describe("recipes defer the CLI invocation to the renderer", () => { expect(stale).toEqual([]); }); + // The narrowing is only worth having if it still catches what it is for, so + // this pins both halves: prose is ignored, code is not. + it.each([ + ["the taskless test suite is green", false, "prose naming a subcommand"], + ["run the taskless check before pushing", false, "prose, unfenced"], + ["`taskless check`", true, "inline code span"], + ["`npx @taskless/cli check`", true, "hardcoded launcher, inline"], + ["`%(TASKLESS_CLI)s check`", false, "the correct form"], + ["a rule under `.taskless/rules/foo.yml`", false, "a path, not a binary"], + ])("scans %j → flagged=%s (%s)", (line, expected) => { + const flagged = codeFragments(line).some((fragment) => + HARDCODED_INVOCATION.test(fragment.text) + ); + expect(flagged).toBe(expected); + }); + + it("still flags a raw invocation on its own line inside a fence", () => { + const fenced = ["Run it:", "```", "taskless check", "```"].join("\n"); + const flagged = codeFragments(fenced).some((fragment) => + HARDCODED_INVOCATION.test(fragment.text) + ); + expect(flagged).toBe(true); + }); + it("renders a real invocation into every recipe that names the CLI", async () => { const rendered = await renderedRecipes(); // Same build-target reasoning as CLI_NAMES: a prod build with no detected From 2861d6f8aff7affebfca8c6c1710dc5e5e68b3cf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 14:36:30 -0700 Subject: [PATCH 12/12] fix(cli): resolve the invocation on the onboard path, tighten the npx signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `taskless onboard` is the only serving path for `onboard.txt` — it is not a topic `agent` dispatches — and it called `getRecipe("onboard")` with no options, so `RecipeOptions.invocation` was never set. Every `%(TASKLESS_CLI)s` in that recipe rendered as the agent-fill marker for anyone on a published build. Before this stack the file hardcoded `npx @taskless/cli`, which was already correct on prod, so the normalization had made that one recipe strictly worse. Reproduced under a simulated npx launch before fixing, and the two new tests were confirmed to fail without the fix. The existing byte-parity test could not catch it: both paths spawn a bare `node dist/index.js`, where detection correctly returns undefined for both, so they agreed on marker-filled output while taking different code paths. Only a launcher-shaped environment separates them. A sweep of every recipe-serving call site found no third one. Also applies to the npx cache signal the rigor `inPnpmDlxCache` already had: `_npx` must have at least one segment after it, so a path merely ending in a directory of that name is not read as a cache root. The check is deliberately asymmetric with the pnpm one and says why — the npm cache location is configurable, so unlike a pnpm store the parent segment has no fixed name to validate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- packages/cli/src/commands/onboard.ts | 13 +++++- packages/cli/src/util/package-manager.ts | 19 ++++++++- packages/cli/test/onboard.test.ts | 50 +++++++++++++++++++++++ packages/cli/test/package-manager.test.ts | 8 ++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 14945c7b..4937feea 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -7,6 +7,10 @@ import { readManifest, writeManifest } from "../filesystem/migrate"; import { getRecipe } from "../prompts/recipes"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; +import { + detectCliInvocation, + processLauncherContext, +} from "../util/package-manager"; /** * One-line trailer printed by `taskless init` (and the wizard) after a @@ -95,7 +99,14 @@ export const onboardCommand = defineCommand({ return; } - const recipe = getRecipe("onboard"); + // Detected here rather than inside the prompts module, which Workers + // import without `nodejs_compat`. `taskless onboard` is the ONLY serving + // path for this recipe — it is not a topic `agent` dispatches — so + // omitting this renders every invocation in it as the agent-fill marker + // for anyone running a published build. + const recipe = getRecipe("onboard", { + invocation: detectCliInvocation(processLauncherContext()), + }); if (recipe === undefined) { // Should not happen — onboard.txt is embedded at build time. console.error("Internal error: onboard recipe is not available."); diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 2fe06fe7..04cab99a 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -80,6 +80,23 @@ function inPnpmDlxCache(segments: readonly string[]): boolean { ); } +/** + * Whether `argv[1]` runs out of npx's package cache, as opposed to merely + * passing through some directory named `_npx`. + * + * The cache shape is `/_npx//node_modules/.bin/`, so a + * real hit has at least one segment after `_npx`. Unlike {@link inPnpmDlxCache} + * this cannot also validate the segment *before* it: the npm cache location is + * configurable (`npm_config_cache`), so the parent has no fixed name to check. + * The trailing-segment requirement is the part that is checkable, and a + * directory named `_npx` at the very end of a path is not a cache root. + */ +function inNpxCache(segments: readonly string[]): boolean { + return segments.some( + (segment, index) => segment === "_npx" && index + 1 < segments.length + ); +} + /** * Which launcher started this process, or `undefined` when nothing says. * @@ -94,7 +111,7 @@ export function detectLauncher(context: LauncherContext): Launcher | undefined { // npx: the cache directory is the strong signal; the env pair is what npx // sets for the script it runs, and covers a launch whose path was resolved // through a symlink. - if (segments.includes("_npx")) return "npx"; + if (inNpxCache(segments)) return "npx"; if ( context.env.npm_command === "exec" && context.env.npm_lifecycle_event === "npx" diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index 045c678b..3d95d5f4 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -201,6 +201,56 @@ describe("taskless onboard", () => { }); }); +// #141: `taskless onboard` is the ONLY serving path for onboard.txt — it is +// not a topic `agent` dispatches — so it must detect and pass the invocation +// itself. The byte-parity test above cannot catch a regression here: both +// paths spawn a bare `node dist/index.js`, under which detection correctly +// returns undefined for BOTH, so they agree on marker-filled output while +// taking different code paths. Only a launcher-shaped environment separates +// them. +describe("onboard renders a real invocation, not the agent-fill marker", () => { + let cwd: string; + + // The env npx sets, per the observations behind `detectLauncher`. + const npxEnvironment = { + npm_config_user_agent: "npm/11.8.0 node/v24.13.1 darwin arm64", + npm_lifecycle_event: "npx", + npm_command: "exec", + }; + + async function runUnderNpx(args: string[]): Promise { + const { stdout } = await execFileAsync("node", [binPath, ...args], { + cwd, + env: { ...process.env, ...npxEnvironment }, + }); + return stdout; + } + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-onboard-invocation-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("resolves the invocation when launched through npx", async () => { + const stdout = await runUnderNpx(["onboard", "-d", cwd]); + + expect(stdout).toContain("npx @taskless/cli"); + expect(stdout).not.toContain(""); + }); + + it("agrees with `agent onboard` under the same launcher", async () => { + // The parity the bare-spawn test intends to assert, under an environment + // where the two paths can actually disagree. + const viaOnboard = await runUnderNpx(["onboard", "--force", "-d", cwd]); + const viaAgent = await runUnderNpx(["agent", "onboard", "-d", cwd]); + + expect(viaOnboard.trim()).toBe(viaAgent.trim()); + }); +}); + // #140: the bullet list is authored before anything establishes what this // repository can express, so an unroutable candidate reaches the user looking // exactly like a good one. These guard the ordering and the annotation that diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index 158edb5a..c6535ba7 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -39,6 +39,14 @@ describe("detectLauncher", () => { }), "npx", ], + [ + // The npx counterpart to the `dlx` false positive below: a trailing + // directory someone named `_npx` is not a cache root, and nothing else + // in this context says npx. + "a path ending in a directory named _npx, with no npm env", + context("/repo/tools/_npx", { npm_config_user_agent: NPM_AGENT }), + undefined, + ], [ "npx, by the environment it sets for the script it runs", context("/somewhere/opaque/taskless", {