From e1ecbbc21806aada3886e710c463c167711ecd80 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 22:55:51 -0700 Subject: [PATCH 01/10] docs(openspec): propose cli-version-pin-gate --- .../cli-version-pin-gate/.openspec.yaml | 2 + .../changes/cli-version-pin-gate/design.md | 63 ++++++++++++ .../changes/cli-version-pin-gate/proposal.md | 30 ++++++ .../cli-version-pin-gate/specs/cli/spec.md | 95 +++++++++++++++++++ .../changes/cli-version-pin-gate/tasks.md | 21 ++++ 5 files changed, 211 insertions(+) create mode 100644 openspec/changes/cli-version-pin-gate/.openspec.yaml create mode 100644 openspec/changes/cli-version-pin-gate/design.md create mode 100644 openspec/changes/cli-version-pin-gate/proposal.md create mode 100644 openspec/changes/cli-version-pin-gate/specs/cli/spec.md create mode 100644 openspec/changes/cli-version-pin-gate/tasks.md diff --git a/openspec/changes/cli-version-pin-gate/.openspec.yaml b/openspec/changes/cli-version-pin-gate/.openspec.yaml new file mode 100644 index 00000000..9076a660 --- /dev/null +++ b/openspec/changes/cli-version-pin-gate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-20 diff --git a/openspec/changes/cli-version-pin-gate/design.md b/openspec/changes/cli-version-pin-gate/design.md new file mode 100644 index 00000000..24710ae7 --- /dev/null +++ b/openspec/changes/cli-version-pin-gate/design.md @@ -0,0 +1,63 @@ +## Context + +See proposal.md for the motivation. The pieces this builds on already exist: + +- `install.launchers` and `install.cliVersion` in `taskless.json`, and `recordedLauncherVersion()` in `install/launcher.ts`, which answers "what version does this package's launcher run". The gate asks the same question rather than defining a second notion of "the recorded version". +- `buildLauncherPackage()`, which names the package this build belongs to and returns `undefined` for a `self` build. +- `rules.engines`, written by `update --rules` (`recordReconciliation`) and nowhere else, so it is an honest record of what the rules were last reconciled against. +- `requireCurrentSchema()`, the scaffold-layout gate `check` and `verify` already call and already render as a JSON envelope or prose. +- `--allow-version-mismatches`, currently honoured only by the scaffold gate. + +## Goals / Non-Goals + +**Goals:** + +- The CLI that runs a project's rules is the CLI the project recorded, and the rules were reconciled against that CLI's engines. +- Each refusal names a remedy that works from where the reader stands. + +**Non-Goals:** + +- Running migrations from `check`. `check` never writes to the rules tree (see `cli-check`), and the ledger walk can carry judgment a migration cannot. +- Protecting CLIs already published. They do not contain the gate. The engine gate's "ahead" branch exists for CLIs released after this one. +- Gating `validate`. It checks rule shape against this CLI's schema and is what an agent runs while mid-walk. + +## Decisions + +### The pin gate runs once, in the entry point, before dispatch + +It goes in `index.ts` after the `--help`/`--version` intercepts and before `runCommand`, keyed on the first positional (`splitRawArguments`). One place means no subcommand can forget it, and the exemption list is visible in one spot. The alternative, a call in each command's `run`, is how `requireCurrentSchema` ended up with two call sites carrying different dead fallbacks. + +Exempt: no subcommand, `init`, `info`, `auth`, `feedback`. `update` is NOT exempt even though it writes the manifest: it moves `rules.reconciledTo` and `rules.engines`, not the pin, so `npx @taskless/cli@latest update --rules` is exactly the call this exists to stop. + +### "Recorded" means the version this package's launcher runs + +`recordedLauncherVersion(buildLauncherPackage(), install.launchers, install.cliVersion)`. A release CLI compares against the release launcher, a nightly against the nightly one, so a project carrying both does not refuse either. When that returns `undefined` but the manifest records some version, the project is pinned to the other channel and the CLI refuses: this is the "any mismatch" choice, applied to channels too. + +Not gated: no manifest, an unreadable manifest (the command it reaches reports `SCAFFOLD_MANIFEST_UNREADABLE` itself, with the right remedy), a manifest with no version, a `self` build, and a build whose version is `unknown`. + +### The remedy depends on direction + +Newer than recorded: run the launcher (`./.taskless/bin/cli.mjs`), or `init` with this CLI to move the project to it. Older than recorded: run the launcher. Suggesting `init` there would invite a downgrade. The launcher is named only when its file exists, since projects installed before launchers have none. + +### The engine gate is a helper `check` and `verify` call next to `requireCurrentSchema` + +`requireReconciledEngines(cwd)` in `rules/engine-gate.ts` throws a `CLIError` with `RULES_ENGINE_MISMATCH`, so both callers' existing handlers render it as an envelope or prose without new code paths. It is a separate function rather than a branch inside `requireCurrentSchema`, because `migrate.ts` must not grow a dependency on rule listing, and `update --rules` calls `requireCurrentSchema` and must not be refused by the engine gate it exists to clear. + +An engine is compared only when it is recorded and the project has at least one rule for it (`listRuleIds`). Without the second condition a Vale bump would block every ast-grep-only project until it walked a ledger with nothing in it. + +Comparison is by inequality, with `compareVersions` picking the message. Any difference is a mismatch: a patch-level engine bump can move matching semantics as easily as a minor one, which is why the reconciliation records exact versions. + +### The override grows an environment form + +`hasVersionMismatchOverride()` also reads `TASKLESS_ALLOW_VERSION_MISMATCHES` (`1` or `true`). Both gates call it. The root `cli` script sets it, because this repository's `pnpm lint` runs a fresh prod build against a manifest pinned to an older release and engines recorded several upgrades ago. That is the expected state of a development checkout, and CLAUDE.md already says `pnpm cli` is deliberately not the pin. An environment variable rather than a flag in the script, because a flag before the subcommand would have to survive citty's subcommand resolution. + +## Risks / Trade-offs + +- [A user upgrades with `init` and CI goes red on `check` until someone runs `update --rules`] → Intended: those rules have not been reconciled for the new engines. The refusal names both commands. Engine bumps are infrequent. +- [A `devDependencies` bump without `init` refuses everywhere] → Intended under "any mismatch". The message names `init`. +- [This repository's `pnpm lint` stops exercising the gate] → Unit tests cover the gate directly, and the dogfood run keeps exercising everything after it. +- [Tests that spawn the built CLI against fixture manifests recording other versions start failing] → Fixtures that record a version for reasons unrelated to the gate get updated to record none, or the test sets the override. + +## Migration Plan + +None beyond the release note. Rollback is reverting the change; nothing is written to disk by either gate. diff --git a/openspec/changes/cli-version-pin-gate/proposal.md b/openspec/changes/cli-version-pin-gate/proposal.md new file mode 100644 index 00000000..c7803f75 --- /dev/null +++ b/openspec/changes/cli-version-pin-gate/proposal.md @@ -0,0 +1,30 @@ +## Why + +Engine upgrades no longer carry backward-compatibility weight: rule migrations rewrite whatever an ast-grep or Vale release breaks, so a project can always take the newest engines. What still breaks is running a CLI against rules reconciled for different engines, and nothing stops that today. The only version gate is the scaffold layout number, which rule migrations deliberately leave alone, so: + +- a new CLI runs its new engines against rules nobody has migrated (the ast-grep 0.51.0 Haskell rename leaves `check` with no ast-grep findings at all), and +- an older CLI, typically a `devDependencies` pin in CI, runs rules that `npx @taskless/cli@latest update --rules` already migrated past it. + +## What Changes + +- **BREAKING (behavior)**: The CLI refuses to run any command other than `init`, `info`, `auth`, and `feedback` when its version is not the version the project recorded for its package (`install.launchers`, falling back to `install.cliVersion`). `init` is the one command that moves the pin, so upgrading becomes `init` and everything else runs on the recorded version. This applies however the CLI was started: npx, `pnpm dlx`, a `devDependencies` install, or a stale launcher. A project with no recorded version, and a `self` build, are not gated. +- `check` and `verify` refuse to run rules when `rules.engines` in `taskless.json` names an engine version different from the one this CLI bundles. Behind asks for the ledger walk and `update --rules`; ahead asks for a CLI upgrade. A project that never recorded engines is not gated. +- Two new error codes, `CLI_VERSION_MISMATCH` and `RULES_ENGINE_MISMATCH`, so an agent can tell the two remedies apart. +- `--allow-version-mismatches`, today the escape hatch for a scaffold newer than the CLI, also skips both new gates, and can be set as `TASKLESS_ALLOW_VERSION_MISMATCHES=1`. The repo's own `pnpm cli` script sets it, since a development build is deliberately not the pinned version. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli`: adds the version-pin gate, the engine-version gate, and the environment form of the mismatch override. + +## Impact + +- `packages/cli/src/index.ts` (gate before dispatch), a new `packages/cli/src/install/version-gate.ts`, `packages/cli/src/rules/engine-gate.ts`, `commands/check.ts`, `commands/verify.ts`, `filesystem/migrate.ts` (override reads the environment), `types/errors.ts`. +- Root `package.json` `cli` script. +- Any user whose CI runs a pinned CLI older than the project's recorded version gets a refusal naming the fix instead of a quietly wrong scan. Ships as `patch`: the package is pre-1.0. +- Delivery shape: **single PR**. The pin gate and the engine gate are one idea (the CLI that runs must match what the project recorded), and the whole change fits one reviewable diff. diff --git a/openspec/changes/cli-version-pin-gate/specs/cli/spec.md b/openspec/changes/cli-version-pin-gate/specs/cli/spec.md new file mode 100644 index 00000000..6ac1f461 --- /dev/null +++ b/openspec/changes/cli-version-pin-gate/specs/cli/spec.md @@ -0,0 +1,95 @@ +## ADDED Requirements + +### Requirement: The CLI runs only as the version the project recorded + +When `.taskless/taskless.json` records a version for the package the running CLI belongs to, the CLI SHALL refuse to run any subcommand other than `init`, `info`, `auth`, and `feedback` unless its own version equals that recorded version. The recorded version for a package SHALL be its `install.launchers` entry, falling back to `install.cliVersion` when that value belongs to the package (a nightly version has a prerelease part, a release version has none), which is the same version that package's project launcher runs. + +`init` is exempt because it is the command that moves the recorded version. `info`, `auth`, and `feedback` are exempt because they read no rules and serve no recipes, and `info` is where a reader diagnoses the refusal. A bare invocation with no subcommand, `--help`, and `--version` SHALL NOT be refused. + +The refusal SHALL apply however the CLI was started, including npx, `pnpm dlx`, a package installed under `node_modules`, and a project launcher. It SHALL exit non-zero with the error code `CLI_VERSION_MISMATCH`, SHALL name the running version and the recorded version, and SHALL name the remedy: running the project launcher, which runs the recorded version, or, when the running CLI is newer, running `init` with it to move the project to it. With `--json` set, the refusal SHALL be printed as the standard error envelope on stdout. + +The CLI SHALL NOT refuse when the project has no readable `taskless.json`, when the manifest records no version at all, or when the running build is a development (`self`) build, which has no published version to match. A manifest that records a version only for the other package (a release CLI in a project that recorded only a nightly, or the reverse) SHALL be refused. + +#### Scenario: A newer CLI started through npx is refused + +- **WHEN** a project's manifest records `install.launchers["@taskless/cli"]` as `0.13.2` +- **AND** a user runs `npx @taskless/cli@0.14.0 check` in that project +- **THEN** the CLI SHALL exit non-zero with `CLI_VERSION_MISMATCH` without scanning +- **AND** the message SHALL name `0.14.0`, `0.13.2`, the project launcher, and `init` + +#### Scenario: An older pinned CLI is refused + +- **WHEN** the manifest records `0.14.0` for `@taskless/cli` +- **AND** CI runs `@taskless/cli` `0.13.2` from `node_modules` +- **THEN** any gated subcommand SHALL exit non-zero with `CLI_VERSION_MISMATCH` + +#### Scenario: init moves the pin + +- **WHEN** the manifest records `0.13.2` and a user runs `npx @taskless/cli@0.14.0 init --no-interactive` +- **THEN** `init` SHALL run +- **AND** a following `check` through the project launcher SHALL NOT be refused by this gate + +#### Scenario: Exempt subcommands run on any version + +- **WHEN** the running version does not match the recorded version +- **THEN** `info`, `auth status`, `feedback`, `--help`, and `--version` SHALL run normally + +#### Scenario: A project with no recorded version is not gated + +- **WHEN** the project has no `.taskless/taskless.json`, or its manifest records neither `install.launchers` nor `install.cliVersion` +- **THEN** every subcommand SHALL run on any version + +#### Scenario: A JSON caller gets the envelope + +- **WHEN** a gated subcommand is refused with `--json` set +- **THEN** stdout SHALL hold `{"ok": false, "code": "CLI_VERSION_MISMATCH", "message": ...}` + +### Requirement: Rule-running commands refuse rules reconciled against other engine versions + +`check` and `verify` SHALL refuse to run when `rules.engines` in `taskless.json` records a version for an engine that differs from the version of that engine this CLI bundles, and the project has at least one rule for that engine. They SHALL exit non-zero with the error code `RULES_ENGINE_MISMATCH` before running any engine, and the message SHALL name each differing engine with its recorded and bundled versions. + +When a recorded engine is older than the bundled one, the message SHALL direct the reader to the ledger walk (`update`) followed by `update --rules`, which applies any rule migrations and records the bundled engines. When a recorded engine is newer, the message SHALL direct the reader to the CLI the project recorded, since the rules were reconciled by a newer CLI than this one. + +An engine the manifest does not record, and an engine with no rules in the project, SHALL NOT cause a refusal: a project that never recorded a reconciliation has no claim to compare against, and an engine with no rules has nothing that could run wrongly. + +#### Scenario: Rules behind the bundled engine are refused + +- **WHEN** `rules.engines.sg` is `0.45.3`, this CLI bundles ast-grep `0.51.0`, and the project has an ast-grep rule +- **THEN** `check` SHALL exit non-zero with `RULES_ENGINE_MISMATCH` and run no engine +- **AND** the message SHALL name ast-grep `0.45.3` and `0.51.0` and `update --rules` + +#### Scenario: Rules ahead of the bundled engine are refused + +- **WHEN** `rules.engines.vale` is newer than the Vale this CLI bundles and the project has a Vale rule +- **THEN** `verify` SHALL exit non-zero with `RULES_ENGINE_MISMATCH` +- **AND** the message SHALL say the rules were reconciled by a newer CLI + +#### Scenario: An engine without rules is not compared + +- **WHEN** `rules.engines.vale` differs from the bundled Vale and the project has no Vale rules +- **THEN** `check` SHALL NOT refuse on account of Vale + +#### Scenario: A project that never recorded engines is not gated + +- **WHEN** the manifest has no `rules.engines` +- **THEN** `check` and `verify` SHALL run as before + +#### Scenario: Reconciling clears the refusal + +- **WHEN** `check` was refused because the rules were behind +- **AND** the user runs `update --rules` +- **THEN** a following `check` SHALL NOT be refused by this gate + +### Requirement: The version mismatch override has an environment form + +`--allow-version-mismatches` SHALL skip the version-pin gate and the engine-version gate as well as the scaffold-version refusal it already skips. The environment variable `TASKLESS_ALLOW_VERSION_MISMATCHES`, set to `1` or `true`, SHALL have the same effect as the flag, so a script that runs a deliberately unpinned build can set it once. + +#### Scenario: The flag skips both new gates + +- **WHEN** the running version does not match the recorded version, the recorded engines differ from the bundled ones, and the user runs `check --allow-version-mismatches` +- **THEN** `check` SHALL run + +#### Scenario: The environment variable is equivalent + +- **WHEN** `TASKLESS_ALLOW_VERSION_MISMATCHES=1` is set and the versions do not match +- **THEN** the CLI SHALL behave as though `--allow-version-mismatches` were passed diff --git a/openspec/changes/cli-version-pin-gate/tasks.md b/openspec/changes/cli-version-pin-gate/tasks.md new file mode 100644 index 00000000..50175897 --- /dev/null +++ b/openspec/changes/cli-version-pin-gate/tasks.md @@ -0,0 +1,21 @@ +## 1. Override and error codes + +- [ ] 1.1 Add `CLI_VERSION_MISMATCH` and `RULES_ENGINE_MISMATCH` to `CLIErrorCode` with comments naming each remedy; verify `pnpm typecheck` passes +- [ ] 1.2 Make `hasVersionMismatchOverride()` also honour `TASKLESS_ALLOW_VERSION_MISMATCHES` (`1`/`true`); verify with a unit test covering flag, env, and neither +- [ ] 1.3 Set `TASKLESS_ALLOW_VERSION_MISMATCHES=1` in the root `cli` script; verify `pnpm check:style` still passes once the gates land + +## 2. Version-pin gate + +- [ ] 2.1 Add `install/version-gate.ts`: resolve the recorded version for this build's package, decide exemptions, and build the refusal message by direction; verify with unit tests for match, newer, older, other-channel-only, no manifest, no recorded version, unreadable manifest, `self` build, and override +- [ ] 2.2 Call the gate from `index.ts` after the help/version intercepts and before dispatch, rendering a JSON envelope under `--json`; verify with a spawned-CLI test that `check` is refused and `info`/`init` are not + +## 3. Engine-version gate + +- [ ] 3.1 Add `rules/engine-gate.ts` with `requireReconciledEngines(cwd)`, comparing only engines that are recorded and have rules; verify with unit tests for behind, ahead, unrecorded, no rules for the engine, and override +- [ ] 3.2 Call it from `check` and `verify` beside `requireCurrentSchema`; verify a spawned `check --json` reports `RULES_ENGINE_MISMATCH` and that `update --rules` clears it + +## 4. Fallout and release + +- [ ] 4.1 Run the full CLI test suite and fix fixtures that record versions unrelated to the gates; verify `pnpm test` passes +- [ ] 4.2 Update the `update` recipe or docs that describe upgrading so they say upgrades go through `init` and the launcher; verify the prompt parity tests pass +- [ ] 4.3 Add a `patch` changeset describing both refusals and the override; verify `pnpm typecheck` and `pnpm lint` pass From 77a90ae69cd5c9154e3f0ea7b984571231a7b8b8 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 23:05:02 -0700 Subject: [PATCH 02/10] feat(cli): run only as the recorded version, on rules reconciled for its engines A CLI that is not the version .taskless/taskless.json records for its package now refuses every command but init, info, auth and feedback (CLI_VERSION_MISMATCH), however it was started. check and verify refuse rules whose recorded rules.engines differ from the bundled ast-grep or Vale, for an engine the project has rules for (RULES_ENGINE_MISMATCH). --allow-version-mismatches, now also TASKLESS_ALLOW_VERSION_MISMATCHES, skips both; the repo's pnpm cli sets it. --- .changeset/cli-version-pin-gate.md | 10 + .../changes/cli-version-pin-gate/tasks.md | 20 +- package.json | 2 +- packages/cli/README.md | 8 + packages/cli/src/agent/update.md | 44 ++-- packages/cli/src/commands/check.ts | 4 + packages/cli/src/commands/verify.ts | 5 + packages/cli/src/filesystem/migrate.ts | 27 ++- .../src/filesystem/migrations/0001-init.ts | 15 +- packages/cli/src/index.ts | 31 +++ packages/cli/src/install/version-gate.ts | 218 ++++++++++++++++++ packages/cli/src/rules/engine-gate.ts | 133 +++++++++++ packages/cli/src/types/errors.ts | 11 + packages/cli/test/engine-gate.test.ts | 70 ++++++ packages/cli/test/onboard.test.ts | 10 +- packages/cli/test/reconcile-marker.test.ts | 39 ++++ packages/cli/test/scaffold-readme.test.ts | 15 +- packages/cli/test/support/built-cli.ts | 11 + packages/cli/test/version-gate-cli.test.ts | 105 +++++++++ packages/cli/test/version-gate.test.ts | 170 ++++++++++++++ 20 files changed, 901 insertions(+), 47 deletions(-) create mode 100644 .changeset/cli-version-pin-gate.md create mode 100644 packages/cli/src/install/version-gate.ts create mode 100644 packages/cli/src/rules/engine-gate.ts create mode 100644 packages/cli/test/engine-gate.test.ts create mode 100644 packages/cli/test/version-gate-cli.test.ts create mode 100644 packages/cli/test/version-gate.test.ts diff --git a/.changeset/cli-version-pin-gate.md b/.changeset/cli-version-pin-gate.md new file mode 100644 index 00000000..9254ad2c --- /dev/null +++ b/.changeset/cli-version-pin-gate.md @@ -0,0 +1,10 @@ +--- +"@taskless/cli": patch +--- + +The CLI now runs only as the version a project records, and only on rules reconciled for its engines. + +- **A CLI that is not the version `.taskless/taskless.json` records refuses every command except `init`, `info`, `auth`, and `feedback`**, with `CLI_VERSION_MISMATCH`. That covers npx, `pnpm dlx`, a `devDependencies` install, and any other way of starting it. Run commands through the project launcher, `./.taskless/bin/cli.mjs`, which runs the recorded version, and upgrade by running `init` with the newer CLI. If CI runs a pinned CLI, move the pin in the same commit as the upgrade. +- **`check` and `verify` refuse rules reconciled against a different ast-grep or Vale**, with `RULES_ENGINE_MISMATCH`, when `rules.engines` names an engine version this CLI does not bundle and the project has rules for that engine. Until now they ran the new engine against rules nobody had migrated. A rule still using a renamed kind could leave `check` reporting no ast-grep findings at all. When the rules are behind, run `update` and then `update --rules`, which applies the rule rewrites and records the bundled engines. A project that never recorded its engines is not affected. +- `--allow-version-mismatches` skips both refusals, and `TASKLESS_ALLOW_VERSION_MISMATCHES=1` does the same from the environment. +- The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. diff --git a/openspec/changes/cli-version-pin-gate/tasks.md b/openspec/changes/cli-version-pin-gate/tasks.md index 50175897..b216e7a4 100644 --- a/openspec/changes/cli-version-pin-gate/tasks.md +++ b/openspec/changes/cli-version-pin-gate/tasks.md @@ -1,21 +1,21 @@ ## 1. Override and error codes -- [ ] 1.1 Add `CLI_VERSION_MISMATCH` and `RULES_ENGINE_MISMATCH` to `CLIErrorCode` with comments naming each remedy; verify `pnpm typecheck` passes -- [ ] 1.2 Make `hasVersionMismatchOverride()` also honour `TASKLESS_ALLOW_VERSION_MISMATCHES` (`1`/`true`); verify with a unit test covering flag, env, and neither -- [ ] 1.3 Set `TASKLESS_ALLOW_VERSION_MISMATCHES=1` in the root `cli` script; verify `pnpm check:style` still passes once the gates land +- [x] 1.1 Add `CLI_VERSION_MISMATCH` and `RULES_ENGINE_MISMATCH` to `CLIErrorCode` with comments naming each remedy; verify `pnpm typecheck` passes +- [x] 1.2 Make `hasVersionMismatchOverride()` also honour `TASKLESS_ALLOW_VERSION_MISMATCHES` (`1`/`true`); verify with a unit test covering flag, env, and neither +- [x] 1.3 Set `TASKLESS_ALLOW_VERSION_MISMATCHES=1` in the root `cli` script; verify `pnpm check:style` still passes once the gates land ## 2. Version-pin gate -- [ ] 2.1 Add `install/version-gate.ts`: resolve the recorded version for this build's package, decide exemptions, and build the refusal message by direction; verify with unit tests for match, newer, older, other-channel-only, no manifest, no recorded version, unreadable manifest, `self` build, and override -- [ ] 2.2 Call the gate from `index.ts` after the help/version intercepts and before dispatch, rendering a JSON envelope under `--json`; verify with a spawned-CLI test that `check` is refused and `info`/`init` are not +- [x] 2.1 Add `install/version-gate.ts`: resolve the recorded version for this build's package, decide exemptions, and build the refusal message by direction; verify with unit tests for match, newer, older, other-channel-only, no manifest, no recorded version, unreadable manifest, `self` build, and override +- [x] 2.2 Call the gate from `index.ts` after the help/version intercepts and before dispatch, rendering a JSON envelope under `--json`; verify with a spawned-CLI test that `check` is refused and `info`/`init` are not ## 3. Engine-version gate -- [ ] 3.1 Add `rules/engine-gate.ts` with `requireReconciledEngines(cwd)`, comparing only engines that are recorded and have rules; verify with unit tests for behind, ahead, unrecorded, no rules for the engine, and override -- [ ] 3.2 Call it from `check` and `verify` beside `requireCurrentSchema`; verify a spawned `check --json` reports `RULES_ENGINE_MISMATCH` and that `update --rules` clears it +- [x] 3.1 Add `rules/engine-gate.ts` with `requireReconciledEngines(cwd)`, comparing only engines that are recorded and have rules; verify with unit tests for behind, ahead, unrecorded, no rules for the engine, and override +- [x] 3.2 Call it from `check` and `verify` beside `requireCurrentSchema`; verify a spawned `check --json` reports `RULES_ENGINE_MISMATCH` and that `update --rules` clears it ## 4. Fallout and release -- [ ] 4.1 Run the full CLI test suite and fix fixtures that record versions unrelated to the gates; verify `pnpm test` passes -- [ ] 4.2 Update the `update` recipe or docs that describe upgrading so they say upgrades go through `init` and the launcher; verify the prompt parity tests pass -- [ ] 4.3 Add a `patch` changeset describing both refusals and the override; verify `pnpm typecheck` and `pnpm lint` pass +- [x] 4.1 Run the full CLI test suite and fix fixtures that record versions unrelated to the gates; verify `pnpm test` passes +- [x] 4.2 Update the `update` recipe or docs that describe upgrading so they say upgrades go through `init` and the launcher; verify the prompt parity tests pass +- [x] 4.3 Add a `patch` changeset describing both refusals and the override; verify `pnpm typecheck` and `pnpm lint` pass diff --git a/package.json b/package.json index c76418eb..8380f6a4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "bump:version": "changeset version", "changeset": "changeset", "check:style": "pnpm build && pnpm cli check", - "cli": "TASKLESS_TELEMETRY_DISABLED=1 ./packages/cli/dist/index.js", + "cli": "TASKLESS_TELEMETRY_DISABLED=1 TASKLESS_ALLOW_VERSION_MISMATCHES=1 ./packages/cli/dist/index.js", "dotagents": "dotagents --project", "generate:api": "pnpm --filter @taskless/cli generate:api", "husky": "husky", diff --git a/packages/cli/README.md b/packages/cli/README.md index f70000e2..244c64db 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,6 +26,14 @@ npx @taskless/cli init New to Taskless? Run `npx @taskless/cli onboard` after installing. It walks your agent through your codebase and suggests a starter set of rules. +`init` records its version in `.taskless/taskless.json` and writes a launcher, +`./.taskless/bin/cli.mjs`, that runs exactly that version. Any other version of +the CLI refuses every command except `init`, `info`, `auth`, and `feedback` +with `CLI_VERSION_MISMATCH`, because a project's rules are reconciled against +the CLI it records. So once a project is installed, run commands through the +launcher, and upgrade by running `init` with the newer CLI. The `npx` examples +on this page assume npx resolves the version the project records. + ## How to Use via Agents Installing adds one skill (`taskless`). The skill body is a small router: your diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index 7b1f20fd..ad482ec9 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,4 @@ -# Topic: update (CLI v%(CLI_VERSION)s / topic v18) +# Topic: update (CLI v%(CLI_VERSION)s / topic v19) %(ONE_COMMAND_PER_CALL)s @@ -58,6 +58,14 @@ that you finished. it means for existing rules. A section that says there is nothing to do means exactly that; it is a claim, not an oversight. + Until step 5 records the walk, `check` and `verify` refuse with + `RULES_ENGINE_MISMATCH`: the rules are still recorded against the + engines they were last reconciled for, not the ones this CLI + bundles. When a section asks you to run one of them mid-walk, add + `--allow-version-mismatches`. Mechanical rewrites have not been + applied yet at that point, so a rule a later section says step 5 + rewrites can still fail to load. + 4. **Offer to bump a pinned CLI.** Read `pinnedCli` from the same `info --json` payload as step 1. Each entry is a pin of `@taskless/cli` or `@taskless/cli-nightly` behind the installed CLI: @@ -69,24 +77,17 @@ that you finished. that publishes it: a nightly version exists only on `@taskless/cli-nightly`, a release only on `@taskless/cli`. - If the upgrade also migrated an existing `.taskless/` (`init` printed - a migration, or `init --json` carried `migrated` with `from` above - `0`), this is not optional advice: a CLI that predates the new - schema refuses the project with `SCAFFOLD_VERSION_MISMATCH`, so CI - breaks on the push that carries the migrated files. The bump belongs - in that same commit. Without a migration the pin still reads the - layout, but checks rules against engines this walk has moved past. - - `info --json` does not say whether a migration ran. If you did not - see `init`'s output yourself, treat the bump as required, but say - that you cannot tell whether `.taskless/` was migrated; do not name - schema versions you have not read. + This is not optional advice. A CLI that is not the version + `.taskless/taskless.json` records refuses every command but `init`, + `info`, `auth` and `feedback` with `CLI_VERSION_MISMATCH`, so any + pin left behind breaks CI on the push that carries `init`'s changes. + The bump belongs in that same commit. Offer the user the bump to the installed version, along with reinstalling dependencies. Do not make it silently: a pin can be deliberate, and the bump changes the lockfile. The walk does not - depend on the answer, so record it either way, but if they decline - after a migration, tell them plainly that CI will fail. + depend on the answer, so record it either way, but if they decline, + tell them plainly that whatever runs that pin will fail. 5. **Record that you finished.** Run: ``` @@ -571,6 +572,15 @@ it never touches a signed rule. A rule with a case that reads through a `validate` keeps naming it; move that rule's cases by hand the same way. +**The CLI now runs only as the version the project records, and only +on rules reconciled for its engines.** Any command but `init`, `info`, +`auth` and `feedback` refuses with `CLI_VERSION_MISMATCH` when the +running CLI is not the version `.taskless/taskless.json` records, and +`check` and `verify` refuse with `RULES_ENGINE_MISMATCH` when +`rules.engines` names an engine version this CLI does not bundle. You +are already doing what clears both: `init` moved the record to this +CLI, step 4 moves the pins, and step 5 records the engines. + ## Errors With `--json`, `--rules` failures emit `{ ok: false, code, message }`: @@ -579,6 +589,10 @@ With `--json`, `--rules` failures emit `{ ok: false, code, message }`: |-----------------|------------------------------------------------|--------------------------------------------| | `INVALID_INPUT` | no `.taskless/`, or an older CLI would rewind the marker | set the project up, or upgrade first | | `SCAFFOLD_MIGRATION_REQUIRED` | the `.taskless/` layout is behind this CLI, so rule rewrites have nowhere safe to write | run `%(TASKLESS_CLI)s init`, then `update --rules` again | +| `CLI_VERSION_MISMATCH` | this CLI is not the version the project records | run the project launcher, `./.taskless/bin/cli.mjs`, or `init` with this CLI to move the project to it | + +`check` and `verify` refuse with `RULES_ENGINE_MISMATCH` until step 5 +has run; see step 3. ## See Also diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 94a83e8e..3530500e 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -19,6 +19,7 @@ import { outputSchema as checkOutputSchema } from "../schemas/check"; import { makeErrorEnvelope, writeJsonError } from "../types/errors"; import { CLIError } from "../util/cli-error"; import { requireCurrentSchema } from "../filesystem/migrate"; +import { requireReconciledEngines } from "../rules/engine-gate"; import { discoverRuntimeRules } from "../rules/runtime/discover"; import { resolveRuleSelection, type RuleSelection } from "../rules/rule-filter"; import { scanProject } from "../rules/scan-project"; @@ -341,6 +342,9 @@ export const checkCommand = defineCommand({ // one check that would catch it. try { await requireCurrentSchema(cwd); + // And the rules must have been reconciled for the engines this CLI + // bundles; `check` refuses rather than running the rewrites itself. + await requireReconciledEngines(cwd); } catch (error) { // Handled here rather than left to the outer handler, which prints // prose: `--json` callers branch on the code, and this refusal asks diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 5e411123..76350732 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -5,6 +5,7 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { requireCurrentSchema } from "../filesystem/migrate"; +import { requireReconciledEngines } from "../rules/engine-gate"; import type { FixtureFinding } from "../rules/fixtures"; import { verifyOneRule, @@ -99,6 +100,10 @@ async function runOverPath(options: { // and name the thing that fixes it. try { await requireCurrentSchema(cwd); + // `verify` runs the engines, so it holds the rules to the engines they + // were reconciled for. `validate` does not: it checks shape, and is what + // an agent runs part-way through the walk that clears this refusal. + if (label === "verify") await requireReconciledEngines(cwd); } catch (error) { if (error instanceof CLIError) { if (json) { diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index 3d43ef79..f2e21a92 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -37,15 +37,30 @@ const migrations: Migrations = { export const ALLOW_VERSION_MISMATCHES_FLAG = "--allow-version-mismatches"; /** - * Whether the invocation opted out of scaffold-version enforcement. Read from - * raw argv rather than a parsed command, because every command reaches the - * migration runner through {@link ensureTasklessDirectory} and none of them - * thread their own options down to it. + * The environment form of {@link ALLOW_VERSION_MISMATCHES_FLAG}, for a script + * that runs a deliberately unpinned build (this repository's `pnpm cli`) and + * would otherwise have to thread the flag past citty's subcommand resolution. + */ +export const ALLOW_VERSION_MISMATCHES_ENV = "TASKLESS_ALLOW_VERSION_MISMATCHES"; + +/** + * Whether the invocation opted out of version enforcement: the scaffold + * version, the CLI version the project records, and the engine versions its + * rules were reconciled against. Read from raw argv rather than a parsed + * command, because every command reaches the migration runner through + * {@link ensureTasklessDirectory} and none of them thread their own options + * down to it. */ export function hasVersionMismatchOverride( - rawArguments: string[] = process.argv.slice(2) + rawArguments: string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env ): boolean { - return rawArguments.includes(ALLOW_VERSION_MISMATCHES_FLAG); + const value = env[ALLOW_VERSION_MISMATCHES_ENV]?.toLowerCase(); + return ( + rawArguments.includes(ALLOW_VERSION_MISMATCHES_FLAG) || + value === "1" || + value === "true" + ); } /** Sort migration keys numerically and return [version, migration] pairs */ diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index d70cb576..c9cce149 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -45,9 +45,10 @@ function usageBlock(specifier: string | undefined): string { return `${buildInvocation()} check`; } // The project launcher `init` writes beside this file, named for the - // package the specifier names. Listed first: it is what the installed skill - // and CI run, and it needs no package manager at all. The launchers below - // are for a checkout where it is not there yet. + // package the specifier names. It is the way to run `check`: it runs the + // version the project records, and any other version refuses everything + // but `init`. So the package-manager lines run `init`, which writes the + // launcher in a checkout without one and moves the project to a newer CLI. const [release, nightly] = LAUNCHER_PACKAGES; const launcher = launcherInvocation( specifier.startsWith(`${nightly}@`) ? nightly : release @@ -56,11 +57,11 @@ function usageBlock(specifier: string | undefined): string { "# the project launcher, from the repository root", `${launcher} check`, "", - "# npm / pnpm", - `pnpm dlx ${specifier} check`, + "# install the launcher, or upgrade the project (npm / pnpm)", + `pnpm dlx ${specifier} init`, "", - "# npx", - `npx ${specifier} check`, + "# the same, with npx", + `npx ${specifier} init`, ].join("\n"); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5d893f74..d6ae3b0b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -34,6 +34,8 @@ import { shouldLaunchWizard } from "./util/interactive"; import { showResolvedUsage } from "./util/help"; import { getCliPrefix } from "./util/package-manager"; import { CLIError } from "./util/cli-error"; +import { requireRecordedVersion } from "./install/version-gate"; +import { writeJsonError } from "./types/errors"; // zod registers its English messages as a side effect of importing "zod", but // its package.json declares `"sideEffects": false`, so the bundler drops that @@ -161,6 +163,34 @@ const main = defineCommand({ }, }); +/** + * The version-pin gate, rendered the way the subcommand would render it: a + * JSON envelope on stdout under `--json`, prose on stderr otherwise. + */ +async function requireRecordedVersionForRun( + rawArguments: string[] +): Promise { + const { positionals } = splitRawArguments(rawArguments); + try { + await requireRecordedVersion( + resolveCwd(rawArguments), + positionals[0], + rawArguments + ); + } catch (error) { + if ( + error instanceof CLIError && + error.code !== undefined && + rawArguments.includes("--json") + ) { + writeJsonError(error.code, error.message); + process.exitCode = 1; + throw new CLIError(error.message, error.code, { reported: true }); + } + throw error; + } +} + // main loop to run cli and make every attempt to shut down gracefully // `-d ` is joined into `--dir=` so citty's subcommand resolution // cannot mistake the path for a command name (see joinDirectoryValues). @@ -184,6 +214,7 @@ try { } else if (hasVersionFlag(rawArguments)) { process.stdout.write(`${__VERSION__}\n`); } else { + await requireRecordedVersionForRun(rawArguments); await runCommand(main, { rawArgs: rawArguments }); } } catch (error) { diff --git a/packages/cli/src/install/version-gate.ts b/packages/cli/src/install/version-gate.ts new file mode 100644 index 00000000..c4543b05 --- /dev/null +++ b/packages/cli/src/install/version-gate.ts @@ -0,0 +1,218 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; + +import { readManifest } from "../filesystem/manifest"; +import { hasVersionMismatchOverride } from "../filesystem/migrate"; +import { CLIError } from "../util/cli-error"; +import { isRecord } from "../util/is-record"; +import { getCliPrefix } from "../util/package-manager"; +import { compareSemver } from "../util/version-compare"; +import { CLI_VERSION } from "../version"; +import { + buildLauncherPackage, + LAUNCHER_PACKAGES, + launcherPath, + recordedLauncherVersion, + type LauncherPackage, +} from "./launcher"; + +/** + * Refusing to run as a CLI the project did not record. + * + * A project's rules are reconciled against one CLI, and through it one + * ast-grep and one Vale. The scaffold `version` cannot say which: rule + * migrations deliberately leave it alone, so that an engine upgrade does not + * break every pinned CI. Without this, `npx @taskless/cli@latest update --rules` + * migrates the rules past the `devDependencies` pin CI runs, and nothing + * refuses either side. + * + * So the CLI that runs has to be the CLI the project recorded, however it was + * started. `init` is the one command that moves that record, which makes it + * the one way to upgrade. + */ + +/** + * Subcommands that run on any version. `init` moves the record. The others + * read no rules and serve no recipes, and `info` is where a reader goes to + * understand this refusal. `update` is NOT here: it moves the rules' record, + * not the CLI's, so running it on another version is exactly the case this + * exists to stop. + */ +const EXEMPT_COMMANDS: ReadonlySet = new Set([ + "init", + "info", + "auth", + "feedback", +]); + +/** A mismatch between this CLI and the version the project records. */ +export interface VersionMismatch { + /** The package and version this process is. */ + runningPackage: LauncherPackage; + running: string; + /** The package and version the project records, which this is not. */ + recordedPackage: LauncherPackage; + recorded: string; +} + +/** Read `install` defensively: a hand-edited manifest can hold anything. */ +function recordedVersions(install: unknown): { + launchers: Record | undefined; + cliVersion: string | undefined; +} { + if (!isRecord(install)) + return { launchers: undefined, cliVersion: undefined }; + const launchers = isRecord(install.launchers) + ? Object.fromEntries( + Object.entries(install.launchers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ) + : undefined; + const cliVersion = + typeof install.cliVersion === "string" ? install.cliVersion : undefined; + return { launchers, cliVersion }; +} + +/** + * The mismatch, or `undefined` when there is nothing to refuse. + * + * "Recorded" is what this package's project launcher would run, so the gate + * and the launcher cannot disagree. A project carrying both a release and a + * nightly launcher refuses neither. A project that records only the other + * package is a mismatch: the choice was any mismatch, and a channel is part of + * the version. + */ +export function findVersionMismatch(input: { + command: string | undefined; + running: string; + runningPackage: LauncherPackage | undefined; + install: unknown; +}): VersionMismatch | undefined { + const { command, running, runningPackage } = input; + if (command === undefined || EXEMPT_COMMANDS.has(command)) return undefined; + // A `self` build runs from this checkout and has no published version to + // match; outside a build the version is unknown. + if (runningPackage === undefined || running === "unknown") return undefined; + + const { launchers, cliVersion } = recordedVersions(input.install); + const own = recordedLauncherVersion(runningPackage, launchers, cliVersion); + if (own !== undefined) { + return own === running + ? undefined + : { + runningPackage, + running, + recordedPackage: runningPackage, + recorded: own, + }; + } + + for (const other of LAUNCHER_PACKAGES) { + if (other === runningPackage) continue; + const version = recordedLauncherVersion(other, launchers, cliVersion); + if (version !== undefined) { + return { + runningPackage, + running, + recordedPackage: other, + recorded: version, + }; + } + } + return undefined; +} + +/** + * The refusal's text. The remedy depends on direction: a newer CLI can move + * the project to itself with `init`, an older one should not be told to, since + * that would be a downgrade. + */ +export function versionMismatchMessage( + mismatch: VersionMismatch, + options: { command: string; launcherExists: boolean; prefix: string } +): string { + const { runningPackage, running, recordedPackage, recorded } = mismatch; + const launcher = `./${launcherPath(recordedPackage)}`; + const sameChannel = runningPackage === recordedPackage; + const newer = !sameChannel || compareSemver(running, recorded) > 0; + + const lines = [ + `This is ${runningPackage} ${running}, and this project runs ${recordedPackage} ${recorded}, as recorded in .taskless/taskless.json. ` + + `A project's rules are reconciled against the CLI it records, so another version does not run them.`, + "", + ]; + const useLauncher = options.launcherExists + ? `run \`${launcher} ${options.command}\`, which runs ${recorded}` + : undefined; + if (newer) { + const moveTo = `run \`${options.prefix} init\` to move the project to ${running}`; + lines.push( + useLauncher === undefined + ? `To use this CLI, ${moveTo}.` + : `To use the recorded version, ${useLauncher}. To use this one, ${moveTo}.` + ); + } else { + lines.push( + useLauncher === undefined + ? `Run ${recordedPackage} ${recorded}: update whatever started this CLI, such as a package.json pin or a script, to that version.` + : `To use the recorded version, ${useLauncher}, or update whatever started this CLI, such as a package.json pin or a script, to ${recorded}.` + ); + } + lines.push( + "", + "Pass --allow-version-mismatches to run anyway, knowing the rules may not match this CLI's engines." + ); + return lines.join("\n"); +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Throw `CLI_VERSION_MISMATCH` when this process is not the CLI the project + * records. Called once, before dispatch, so no subcommand can forget it. + * + * A manifest that cannot be read is not refused here. The command it reaches + * reports `SCAFFOLD_MANIFEST_UNREADABLE` itself, with the remedy that fits. + */ +export async function requireRecordedVersion( + cwd: string, + command: string | undefined, + rawArguments: string[] +): Promise { + if (hasVersionMismatchOverride(rawArguments)) return; + const tasklessDirectory = join(cwd, ".taskless"); + let install: unknown; + try { + const { manifest } = await readManifest(tasklessDirectory); + install = manifest.install; + } catch { + return; + } + + const mismatch = findVersionMismatch({ + command, + running: CLI_VERSION, + runningPackage: buildLauncherPackage(), + install, + }); + if (mismatch === undefined) return; + + throw new CLIError( + versionMismatchMessage(mismatch, { + command: command ?? "", + launcherExists: await exists( + join(cwd, launcherPath(mismatch.recordedPackage)) + ), + prefix: getCliPrefix(), + }), + "CLI_VERSION_MISMATCH" + ); +} diff --git a/packages/cli/src/rules/engine-gate.ts b/packages/cli/src/rules/engine-gate.ts new file mode 100644 index 00000000..536874bb --- /dev/null +++ b/packages/cli/src/rules/engine-gate.ts @@ -0,0 +1,133 @@ +import { join } from "node:path"; + +import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; +import { listRuleIds } from "./engines"; +import { TASKLESS_DIRECTORY } from "./layout"; +import { readManifest } from "../filesystem/manifest"; +import { hasVersionMismatchOverride } from "../filesystem/migrate"; +import { CLIError } from "../util/cli-error"; +import { getCliPrefix } from "../util/package-manager"; +import { compareVersions } from "../util/version-compare"; + +/** + * Refusing to run rules reconciled against other engine versions. + * + * `rules.engines` is written by `update --rules` and nowhere else, so it is an + * honest record of the ast-grep and Vale the rules were last made valid for. + * An engine upgrade can rename a kind or retire a key, and a rule still in the + * old shape can take every other rule of that engine down with it. Rule + * migrations fix that, but only when `update --rules` runs them, and `check` + * never writes to the rules tree. So `check` and `verify` refuse instead, and + * name the command that reconciles. + * + * Separate from `requireCurrentSchema`, because `update --rules` calls that and + * must not be refused by the gate it exists to clear. + */ + +const ENGINE_LABELS = { sg: "ast-grep", vale: "Vale" } as const; + +type GatedEngine = keyof typeof ENGINE_LABELS; + +const BUNDLED: Record = { + sg: AST_GREP_VERSION, + vale: VALE_VERSION, +}; + +/** One engine whose recorded version is not the bundled one. */ +export interface EngineMismatch { + engine: GatedEngine; + recorded: string; + bundled: string; +} + +/** + * The engines to refuse on. Only an engine that is recorded AND has rules is + * compared: a project that never reconciled has no claim to check, and an + * engine with no rules has nothing that could run wrongly. Without the second + * condition a Vale upgrade would block every ast-grep-only project until it + * walked a ledger with nothing in it. + * + * Any difference counts. A patch-level engine release can move matching as + * easily as a minor one, which is why the reconciliation records exact + * versions. + */ +export function findEngineMismatches( + recorded: unknown, + enginesWithRules: ReadonlySet, + bundled: Record = BUNDLED +): EngineMismatch[] { + if (typeof recorded !== "object" || recorded === null) return []; + const mismatches: EngineMismatch[] = []; + for (const engine of Object.keys(ENGINE_LABELS) as GatedEngine[]) { + const version = (recorded as Record)[engine]; + if (typeof version !== "string" || !enginesWithRules.has(engine)) continue; + if (version !== bundled[engine]) { + mismatches.push({ engine, recorded: version, bundled: bundled[engine] }); + } + } + return mismatches; +} + +/** The refusal's text: what differs, and the remedy for each direction. */ +export function engineMismatchMessage( + mismatches: EngineMismatch[], + prefix: string +): string { + const behind = mismatches.filter( + ({ recorded, bundled }) => compareVersions(recorded, bundled) < 0 + ); + const listed = mismatches + .map( + ({ engine, recorded, bundled }) => + `${ENGINE_LABELS[engine]} ${recorded} (this CLI bundles ${bundled})` + ) + .join(" and "); + const lines = [ + `This project's rules were reconciled against ${listed}, as recorded in .taskless/taskless.json. A rule written for one engine version can fail to load on another, so they are not run.`, + "", + ]; + if (behind.length === mismatches.length) { + lines.push( + `Run \`${prefix} update\` and follow what it says changed, then \`${prefix} update --rules\`, which applies any rule rewrites and records the engines this CLI bundles.` + ); + } else { + lines.push( + "The rules were reconciled by a newer CLI than this one. Run the CLI the project records, such as its launcher under .taskless/bin/." + ); + } + lines.push( + "", + "Pass --allow-version-mismatches to run anyway, knowing some rules may not load." + ); + return lines.join("\n"); +} + +/** + * Throw `RULES_ENGINE_MISMATCH` when the rules were reconciled against an + * engine version this CLI does not bundle. A manifest that cannot be read is + * left to `requireCurrentSchema`, which callers run first. + */ +export async function requireReconciledEngines(cwd: string): Promise { + if (hasVersionMismatchOverride()) return; + let recorded: unknown; + try { + const { manifest } = await readManifest(join(cwd, TASKLESS_DIRECTORY)); + recorded = manifest.rules?.engines; + } catch { + return; + } + if (recorded === undefined) return; + + const enginesWithRules = new Set(); + for (const engine of Object.keys(ENGINE_LABELS) as GatedEngine[]) { + const ids = await listRuleIds(cwd, engine); + if (ids.length > 0) enginesWithRules.add(engine); + } + + const mismatches = findEngineMismatches(recorded, enginesWithRules); + if (mismatches.length === 0) return; + throw new CLIError( + engineMismatchMessage(mismatches, getCliPrefix()), + "RULES_ENGINE_MISMATCH" + ); +} diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index a6d691b8..ee2681d8 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -91,6 +91,17 @@ export type CLIErrorCode = // `rules`. An agent reading this should repair or delete the file, never // migrate. | "SCAFFOLD_MANIFEST_UNREADABLE" + // The running CLI is not the version `.taskless/taskless.json` records for + // its package. Not a scaffold code: the layout may be identical. The remedy + // is to run the version the project records (its launcher), or `init` with + // this CLI to move the project to it. + | "CLI_VERSION_MISMATCH" + // `rules.engines` names an ast-grep or Vale version other than the one this + // CLI bundles, for an engine the project has rules for. Behind asks for the + // `update` walk and `update --rules`; ahead asks for the CLI the project + // recorded. Distinct from the code above because a matching CLI can still + // hold rules nobody reconciled after an upgrade. + | "RULES_ENGINE_MISMATCH" | "SCAFFOLD_CONFLICT" // `taskless report`. INSUFFICIENT is the gate (fewer than three working // Taskless rules) and is the one a recipe answers by asking the user; diff --git a/packages/cli/test/engine-gate.test.ts b/packages/cli/test/engine-gate.test.ts new file mode 100644 index 00000000..2b6bbcf0 --- /dev/null +++ b/packages/cli/test/engine-gate.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + engineMismatchMessage, + findEngineMismatches, +} from "../src/rules/engine-gate"; + +const BUNDLED = { sg: "0.51.0", vale: "3.24.0" }; +const BOTH = new Set(["sg", "vale"] as const); + +/** + * `rules.engines` records what the rules were last reconciled for. `check` + * and `verify` refuse when it differs from what this CLI bundles, for an + * engine that has rules to run. + */ +describe("finding engine mismatches", () => { + it("passes rules reconciled for the bundled engines", () => { + expect(findEngineMismatches(BUNDLED, BOTH, BUNDLED)).toEqual([]); + }); + + it("reports an engine behind the bundled one", () => { + expect( + findEngineMismatches({ sg: "0.45.3", vale: "3.24.0" }, BOTH, BUNDLED) + ).toEqual([{ engine: "sg", recorded: "0.45.3", bundled: "0.51.0" }]); + }); + + it("reports an engine ahead of the bundled one", () => { + expect( + findEngineMismatches({ sg: "0.51.0", vale: "3.25.0" }, BOTH, BUNDLED) + ).toEqual([{ engine: "vale", recorded: "3.25.0", bundled: "3.24.0" }]); + }); + + it("ignores an engine the project has no rules for", () => { + expect( + findEngineMismatches( + { sg: "0.51.0", vale: "3.18.0" }, + new Set(["sg"] as const), + BUNDLED + ) + ).toEqual([]); + }); + + it("ignores an engine that was never recorded", () => { + expect(findEngineMismatches({ sg: "0.51.0" }, BOTH, BUNDLED)).toEqual([]); + expect(findEngineMismatches(undefined, BOTH, BUNDLED)).toEqual([]); + expect(findEngineMismatches({ sg: 51 }, BOTH, BUNDLED)).toEqual([]); + }); +}); + +describe("the engine mismatch message", () => { + it("sends rules that are behind through update --rules", () => { + const message = engineMismatchMessage( + [{ engine: "sg", recorded: "0.45.3", bundled: "0.51.0" }], + "npx x" + ); + expect(message).toContain("ast-grep 0.45.3 (this CLI bundles 0.51.0)"); + expect(message).toContain("`npx x update`"); + expect(message).toContain("`npx x update --rules`"); + }); + + it("sends rules that are ahead to the recorded CLI", () => { + const message = engineMismatchMessage( + [{ engine: "vale", recorded: "3.25.0", bundled: "3.24.0" }], + "npx x" + ); + expect(message).toContain("Vale 3.25.0"); + expect(message).toContain("newer CLI"); + expect(message).not.toContain("update --rules"); + }); +}); diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index fee3ff1a..4e55009c 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; -import { builtCli } from "./support/built-cli"; +import { builtCli, builtCliVersion } from "./support/built-cli"; const execFileAsync = promisify(execFile); const binPath = builtCli(); @@ -118,14 +118,16 @@ describe("taskless onboard", () => { it("--mark-complete writes onboarded:true and preserves other fields", async () => { // Seed the manifest with an existing install and an unknown top-level - // field; --mark-complete must not clobber either. + // field; --mark-complete must not clobber either. The recorded version is + // the built CLI's own, so the version-pin gate lets `onboard` run. + const cliVersion = builtCliVersion(); await mkdir(join(cwd, ".taskless"), { recursive: true }); await writeFile( join(cwd, ".taskless", "taskless.json"), JSON.stringify({ version: 2, install: { - cliVersion: "0.7.0", + cliVersion, targets: { ".claude": { skills: ["taskless"], commands: ["tskl"] } }, }, experimental: { keep: "me" }, @@ -148,7 +150,7 @@ describe("taskless onboard", () => { targets?: Record; }; expect(install.onboarded).toBe(true); - expect(install.cliVersion).toBe("0.7.0"); + expect(install.cliVersion).toBe(cliVersion); expect(install.targets).toEqual({ ".claude": { skills: ["taskless"], commands: ["tskl"] }, }); diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index 690bca51..396d66c6 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -63,6 +63,12 @@ describe("recording a rules reconciliation", () => { unknown >; delete manifest["rules"]; + // The same leak through `install`: this repository pins whichever release + // last installed its skills, which is not the built CLI, so the + // version-pin gate would refuse every command here. + const install = manifest["install"] as Record; + delete install["cliVersion"]; + delete install["launchers"]; await writeFile(manifestPath, JSON.stringify(manifest, undefined, 2)); }); @@ -134,6 +140,39 @@ describe("recording a rules reconciliation", () => { await writeFile(manifestPath, JSON.stringify(manifest, undefined, 2)); }); + it("check refuses rules behind the bundled engine until --rules runs", async () => { + const refused = await runCli(["check", "--json", "-d", cwd]); + expect(refused.exitCode).not.toBe(0); + const envelope = JSON.parse(refused.stdout) as { + ok: boolean; + code: string; + message: string; + }; + expect(envelope).toMatchObject({ + ok: false, + code: "RULES_ENGINE_MISMATCH", + }); + expect(envelope.message).toContain( + `ast-grep 0.45.3 (this CLI bundles ${AST_GREP_VERSION})` + ); + expect(envelope.message).toContain("update --rules"); + + // The override runs it anyway, which is what the dev loop relies on. + const overridden = await runCli([ + "check", + "--json", + "--allow-version-mismatches", + "-d", + cwd, + ]); + expect(overridden.stdout).not.toContain("RULES_ENGINE_MISMATCH"); + + const reconciled = await runCli(["update", "--rules", "-d", cwd]); + expect(reconciled.exitCode).toBe(0); + const after = await runCli(["check", "--json", "-d", cwd]); + expect(after.stdout).not.toContain("RULES_ENGINE_MISMATCH"); + }); + it("lists what --rules will rewrite before it runs", async () => { const result = await runCli(["update", "--json", "-d", cwd]); const payload = JSON.parse(result.stdout) as { diff --git a/packages/cli/test/scaffold-readme.test.ts b/packages/cli/test/scaffold-readme.test.ts index 0d92c2fc..0625e763 100644 --- a/packages/cli/test/scaffold-readme.test.ts +++ b/packages/cli/test/scaffold-readme.test.ts @@ -23,14 +23,14 @@ const NO_SPECIFIER: string | undefined = undefined; describe("buildReadmeContent", () => { it("offers both launchers for a released build", () => { const readme = buildReadmeContent(RELEASED_SPECIFIER); - expect(readme).toContain(`pnpm dlx ${RELEASED_SPECIFIER} check`); - expect(readme).toContain(`npx ${RELEASED_SPECIFIER} check`); + expect(readme).toContain(`pnpm dlx ${RELEASED_SPECIFIER} init`); + expect(readme).toContain(`npx ${RELEASED_SPECIFIER} init`); }); it("keeps both launchers for a nightly, and names the nightly in both", () => { const readme = buildReadmeContent(NIGHTLY_SPECIFIER); - expect(readme).toContain(`pnpm dlx ${NIGHTLY_SPECIFIER} check`); - expect(readme).toContain(`npx ${NIGHTLY_SPECIFIER} check`); + expect(readme).toContain(`pnpm dlx ${NIGHTLY_SPECIFIER} init`); + expect(readme).toContain(`npx ${NIGHTLY_SPECIFIER} init`); }); it("never names the released package in a nightly's README", () => { @@ -42,6 +42,13 @@ describe("buildReadmeContent", () => { expect(readme).not.toContain("@taskless/cli@"); }); + it("runs only init through a package manager", () => { + // Any version but the recorded one refuses everything except `init`, and + // `@latest` is rarely the recorded one, so `npx … check` would be refused. + const readme = buildReadmeContent(RELEASED_SPECIFIER); + expect(readme).not.toContain(`${RELEASED_SPECIFIER} check`); + }); + it("lists each package's own launcher first", () => { const released = buildReadmeContent(RELEASED_SPECIFIER); expect(released).toContain("./.taskless/bin/cli.mjs check"); diff --git a/packages/cli/test/support/built-cli.ts b/packages/cli/test/support/built-cli.ts index 3b3e6619..76934191 100644 --- a/packages/cli/test/support/built-cli.ts +++ b/packages/cli/test/support/built-cli.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { inject } from "vitest"; @@ -17,3 +18,13 @@ export function builtDirectory(): string { export function builtCli(): string { return join(builtDirectory(), "index.js"); } + +/** + * The version the built CLI reports, for a fixture manifest that records a + * CLI version without meaning to test the version-pin gate. + */ +export function builtCliVersion(): string { + return execFileSync("node", [builtCli(), "--version"], { + encoding: "utf8", + }).trim(); +} diff --git a/packages/cli/test/version-gate-cli.test.ts b/packages/cli/test/version-gate-cli.test.ts new file mode 100644 index 00000000..42069fae --- /dev/null +++ b/packages/cli/test/version-gate-cli.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli, builtCliVersion } from "./support/built-cli"; + +const execFileAsync = promisify(execFile); + +async function runCli( + args: string[], + env: NodeJS.ProcessEnv = {} +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync( + "node", + [builtCli(), ...args], + { env: { ...process.env, TASKLESS_TELEMETRY_DISABLED: "1", ...env } } + ); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + const failure = error as { stdout: string; stderr: string; code?: number }; + return { + stdout: failure.stdout, + stderr: failure.stderr, + exitCode: failure.code ?? 1, + }; + } +} + +/** + * The built CLI, run in a project that records a different version of it. + * The decision itself is unit-tested in version-gate.test.ts; this holds the + * wiring: the gate runs before dispatch, renders under `--json`, and leaves + * the exempt commands alone. + */ +describe("the version-pin gate", () => { + let cwd: string; + + async function record(cliVersion: string): Promise { + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ + version: LATEST_SCHEMA_VERSION, + install: { cliVersion }, + }) + ); + } + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-pin-gate-")); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("refuses check when the project records another version", async () => { + await record("0.0.1"); + const result = await runCli(["check", "--json", "-d", cwd]); + expect(result.exitCode).not.toBe(0); + const envelope = JSON.parse(result.stdout) as { + ok: boolean; + code: string; + message: string; + }; + expect(envelope).toMatchObject({ + ok: false, + code: "CLI_VERSION_MISMATCH", + }); + expect(envelope.message).toContain("0.0.1"); + expect(envelope.message).toContain(builtCliVersion()); + }); + + it("refuses in prose without --json", async () => { + await record("0.0.1"); + const result = await runCli(["rule", "list", "-d", cwd]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("as recorded in .taskless/taskless.json"); + }); + + it("lets info run, so the refusal can be diagnosed", async () => { + await record("0.0.1"); + const result = await runCli(["info", "--json", "-d", cwd]); + expect(result.exitCode).toBe(0); + }); + + it("does not refuse the recorded version", async () => { + await record(builtCliVersion()); + const result = await runCli(["check", "--json", "-d", cwd]); + expect(result.stdout).not.toContain("CLI_VERSION_MISMATCH"); + }); + + it("does not refuse under the environment override", async () => { + await record("0.0.1"); + const result = await runCli(["check", "--json", "-d", cwd], { + TASKLESS_ALLOW_VERSION_MISMATCHES: "1", + }); + expect(result.stdout).not.toContain("CLI_VERSION_MISMATCH"); + }); +}); diff --git a/packages/cli/test/version-gate.test.ts b/packages/cli/test/version-gate.test.ts new file mode 100644 index 00000000..d16fa315 --- /dev/null +++ b/packages/cli/test/version-gate.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; + +import { hasVersionMismatchOverride } from "../src/filesystem/migrate"; +import { + findVersionMismatch, + versionMismatchMessage, +} from "../src/install/version-gate"; + +const RELEASE = "@taskless/cli"; +const NIGHTLY = "@taskless/cli-nightly"; + +function mismatch(install?: unknown, running = "0.14.0", command = "check") { + return findVersionMismatch({ + command, + running, + runningPackage: running.includes("-") ? NIGHTLY : RELEASE, + install, + }); +} + +/** + * The CLI that runs a project's rules must be the CLI the project recorded. + * "Recorded" is what that package's launcher runs, so these cases mirror the + * launcher's own resolution. + */ +describe("finding a version mismatch", () => { + it("passes a CLI that is the recorded version", () => { + expect(mismatch({ launchers: { [RELEASE]: "0.14.0" } })).toBeUndefined(); + }); + + it("refuses a newer CLI", () => { + expect(mismatch({ launchers: { [RELEASE]: "0.13.2" } })).toEqual({ + runningPackage: RELEASE, + running: "0.14.0", + recordedPackage: RELEASE, + recorded: "0.13.2", + }); + }); + + it("refuses an older CLI", () => { + expect(mismatch({ cliVersion: "0.15.0" }, "0.14.0")?.recorded).toBe( + "0.15.0" + ); + }); + + it("falls back to cliVersion only when it belongs to the package", () => { + // A nightly cliVersion says nothing about the release launcher, and the + // project records nothing else for the release, so it is the other channel. + expect(mismatch({ cliVersion: "0.15.0-20261020x1" })).toMatchObject({ + recordedPackage: NIGHTLY, + }); + }); + + it("passes either channel in a project carrying both launchers", () => { + const install = { + cliVersion: "0.15.0-20261020x1", + launchers: { [RELEASE]: "0.14.0", [NIGHTLY]: "0.15.0-20261020x1" }, + }; + expect(mismatch(install, "0.14.0")).toBeUndefined(); + expect(mismatch(install, "0.15.0-20261020x1")).toBeUndefined(); + }); + + it("refuses a CLI whose channel the project never recorded", () => { + expect( + mismatch({ launchers: { [NIGHTLY]: "0.15.0-20261020x1" } }, "0.14.0") + ).toMatchObject({ recordedPackage: NIGHTLY }); + }); + + it("does not gate a project that records no version", () => { + expect(mismatch()).toBeUndefined(); + expect(mismatch({ onboarded: true })).toBeUndefined(); + expect(mismatch({ cliVersion: 7, launchers: "nope" })).toBeUndefined(); + }); + + it.each(["init", "info", "auth", "feedback", undefined])( + "does not gate %s", + (command) => { + expect( + findVersionMismatch({ + command, + running: "0.14.0", + runningPackage: RELEASE, + install: { cliVersion: "0.13.2" }, + }) + ).toBeUndefined(); + } + ); + + it("gates update, which moves the rules' record and not the CLI's", () => { + expect( + mismatch({ cliVersion: "0.13.2" }, "0.14.0", "update") + ).toBeDefined(); + }); + + it("does not gate a self build or an unknown version", () => { + const install = { cliVersion: "0.13.2" }; + expect( + findVersionMismatch({ + command: "check", + running: "0.14.0", + runningPackage: undefined, + install, + }) + ).toBeUndefined(); + expect( + findVersionMismatch({ + command: "check", + running: "unknown", + runningPackage: RELEASE, + install, + }) + ).toBeUndefined(); + }); +}); + +describe("the version mismatch message", () => { + const options = { command: "check", launcherExists: true, prefix: "npx x" }; + + it("offers the launcher and init to a newer CLI", () => { + const message = versionMismatchMessage( + mismatch({ cliVersion: "0.13.2" })!, + options + ); + expect(message).toContain("@taskless/cli 0.14.0"); + expect(message).toContain("@taskless/cli 0.13.2"); + expect(message).toContain("`./.taskless/bin/cli.mjs check`"); + expect(message).toContain("`npx x init` to move the project to 0.14.0"); + }); + + it("never offers init to an older CLI", () => { + const message = versionMismatchMessage( + mismatch({ cliVersion: "0.15.0" })!, + options + ); + expect(message).toContain("`./.taskless/bin/cli.mjs check`"); + expect(message).not.toContain("init"); + }); + + it("does not name a launcher the project does not have", () => { + const message = versionMismatchMessage( + mismatch({ cliVersion: "0.13.2" })!, + { ...options, launcherExists: false } + ); + expect(message).not.toContain(".taskless/bin"); + expect(message).toContain("`npx x init`"); + }); +}); + +describe("the version mismatch override", () => { + it("reads the flag", () => { + expect(hasVersionMismatchOverride(["--allow-version-mismatches"], {})).toBe( + true + ); + }); + + it.each(["1", "true", "TRUE"])("reads the environment set to %s", (value) => { + expect( + hasVersionMismatchOverride([], { + TASKLESS_ALLOW_VERSION_MISMATCHES: value, + }) + ).toBe(true); + }); + + it("is off with neither, or an environment value that is not a yes", () => { + expect(hasVersionMismatchOverride([], {})).toBe(false); + expect( + hasVersionMismatchOverride([], { TASKLESS_ALLOW_VERSION_MISMATCHES: "0" }) + ).toBe(false); + }); +}); From 6869bcc293fe00c1e2cbdcf9e0f87c967141912b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 23:05:26 -0700 Subject: [PATCH 03/10] test(cli): init and an unreadable manifest pass the version-pin gate --- packages/cli/test/version-gate-cli.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli/test/version-gate-cli.test.ts b/packages/cli/test/version-gate-cli.test.ts index 42069fae..2a0d8401 100644 --- a/packages/cli/test/version-gate-cli.test.ts +++ b/packages/cli/test/version-gate-cli.test.ts @@ -89,6 +89,20 @@ describe("the version-pin gate", () => { expect(result.exitCode).toBe(0); }); + it("lets init run, since init is what moves the record", async () => { + await record("0.0.1"); + const result = await runCli(["init", "--json", "-d", cwd]); + expect(result.stdout).not.toContain("CLI_VERSION_MISMATCH"); + expect(result.exitCode).toBe(0); + }); + + it("leaves an unreadable manifest to the command's own refusal", async () => { + await writeFile(join(cwd, ".taskless", "taskless.json"), "{ not json"); + const result = await runCli(["check", "--json", "-d", cwd]); + expect(result.stdout).not.toContain("CLI_VERSION_MISMATCH"); + expect(result.stdout).toContain("SCAFFOLD_MANIFEST_UNREADABLE"); + }); + it("does not refuse the recorded version", async () => { await record(builtCliVersion()); const result = await runCli(["check", "--json", "-d", cwd]); From 1ced91af6df61cad110f683b0681750cbc47bae5 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 20 Oct 2026 23:05:40 -0700 Subject: [PATCH 04/10] docs(openspec): archive cli-version-pin-gate --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli/spec.md | 0 .../2026-10-20-cli-version-pin-gate}/tasks.md | 0 openspec/specs/cli/spec.md | 94 +++++++++++++++++++ 6 files changed, 94 insertions(+) rename openspec/changes/{cli-version-pin-gate => archive/2026-10-20-cli-version-pin-gate}/.openspec.yaml (100%) rename openspec/changes/{cli-version-pin-gate => archive/2026-10-20-cli-version-pin-gate}/design.md (100%) rename openspec/changes/{cli-version-pin-gate => archive/2026-10-20-cli-version-pin-gate}/proposal.md (100%) rename openspec/changes/{cli-version-pin-gate => archive/2026-10-20-cli-version-pin-gate}/specs/cli/spec.md (100%) rename openspec/changes/{cli-version-pin-gate => archive/2026-10-20-cli-version-pin-gate}/tasks.md (100%) diff --git a/openspec/changes/cli-version-pin-gate/.openspec.yaml b/openspec/changes/archive/2026-10-20-cli-version-pin-gate/.openspec.yaml similarity index 100% rename from openspec/changes/cli-version-pin-gate/.openspec.yaml rename to openspec/changes/archive/2026-10-20-cli-version-pin-gate/.openspec.yaml diff --git a/openspec/changes/cli-version-pin-gate/design.md b/openspec/changes/archive/2026-10-20-cli-version-pin-gate/design.md similarity index 100% rename from openspec/changes/cli-version-pin-gate/design.md rename to openspec/changes/archive/2026-10-20-cli-version-pin-gate/design.md diff --git a/openspec/changes/cli-version-pin-gate/proposal.md b/openspec/changes/archive/2026-10-20-cli-version-pin-gate/proposal.md similarity index 100% rename from openspec/changes/cli-version-pin-gate/proposal.md rename to openspec/changes/archive/2026-10-20-cli-version-pin-gate/proposal.md diff --git a/openspec/changes/cli-version-pin-gate/specs/cli/spec.md b/openspec/changes/archive/2026-10-20-cli-version-pin-gate/specs/cli/spec.md similarity index 100% rename from openspec/changes/cli-version-pin-gate/specs/cli/spec.md rename to openspec/changes/archive/2026-10-20-cli-version-pin-gate/specs/cli/spec.md diff --git a/openspec/changes/cli-version-pin-gate/tasks.md b/openspec/changes/archive/2026-10-20-cli-version-pin-gate/tasks.md similarity index 100% rename from openspec/changes/cli-version-pin-gate/tasks.md rename to openspec/changes/archive/2026-10-20-cli-version-pin-gate/tasks.md diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 16a8ddc4..6fe1a237 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -573,3 +573,97 @@ The `update` recipe SHALL read the pins from `info --json`, the read-only comman - **WHEN** `taskless info` runs without `--json` and a pin is stale - **THEN** stdout SHALL name the pin's manifest, location, and spec, and the version to move it to + +### Requirement: The CLI runs only as the version the project recorded + +When `.taskless/taskless.json` records a version for the package the running CLI belongs to, the CLI SHALL refuse to run any subcommand other than `init`, `info`, `auth`, and `feedback` unless its own version equals that recorded version. The recorded version for a package SHALL be its `install.launchers` entry, falling back to `install.cliVersion` when that value belongs to the package (a nightly version has a prerelease part, a release version has none), which is the same version that package's project launcher runs. + +`init` is exempt because it is the command that moves the recorded version. `info`, `auth`, and `feedback` are exempt because they read no rules and serve no recipes, and `info` is where a reader diagnoses the refusal. A bare invocation with no subcommand, `--help`, and `--version` SHALL NOT be refused. + +The refusal SHALL apply however the CLI was started, including npx, `pnpm dlx`, a package installed under `node_modules`, and a project launcher. It SHALL exit non-zero with the error code `CLI_VERSION_MISMATCH`, SHALL name the running version and the recorded version, and SHALL name the remedy: running the project launcher, which runs the recorded version, or, when the running CLI is newer, running `init` with it to move the project to it. With `--json` set, the refusal SHALL be printed as the standard error envelope on stdout. + +The CLI SHALL NOT refuse when the project has no readable `taskless.json`, when the manifest records no version at all, or when the running build is a development (`self`) build, which has no published version to match. A manifest that records a version only for the other package (a release CLI in a project that recorded only a nightly, or the reverse) SHALL be refused. + +#### Scenario: A newer CLI started through npx is refused + +- **WHEN** a project's manifest records `install.launchers["@taskless/cli"]` as `0.13.2` +- **AND** a user runs `npx @taskless/cli@0.14.0 check` in that project +- **THEN** the CLI SHALL exit non-zero with `CLI_VERSION_MISMATCH` without scanning +- **AND** the message SHALL name `0.14.0`, `0.13.2`, the project launcher, and `init` + +#### Scenario: An older pinned CLI is refused + +- **WHEN** the manifest records `0.14.0` for `@taskless/cli` +- **AND** CI runs `@taskless/cli` `0.13.2` from `node_modules` +- **THEN** any gated subcommand SHALL exit non-zero with `CLI_VERSION_MISMATCH` + +#### Scenario: init moves the pin + +- **WHEN** the manifest records `0.13.2` and a user runs `npx @taskless/cli@0.14.0 init --no-interactive` +- **THEN** `init` SHALL run +- **AND** a following `check` through the project launcher SHALL NOT be refused by this gate + +#### Scenario: Exempt subcommands run on any version + +- **WHEN** the running version does not match the recorded version +- **THEN** `info`, `auth status`, `feedback`, `--help`, and `--version` SHALL run normally + +#### Scenario: A project with no recorded version is not gated + +- **WHEN** the project has no `.taskless/taskless.json`, or its manifest records neither `install.launchers` nor `install.cliVersion` +- **THEN** every subcommand SHALL run on any version + +#### Scenario: A JSON caller gets the envelope + +- **WHEN** a gated subcommand is refused with `--json` set +- **THEN** stdout SHALL hold `{"ok": false, "code": "CLI_VERSION_MISMATCH", "message": ...}` + +### Requirement: Rule-running commands refuse rules reconciled against other engine versions + +`check` and `verify` SHALL refuse to run when `rules.engines` in `taskless.json` records a version for an engine that differs from the version of that engine this CLI bundles, and the project has at least one rule for that engine. They SHALL exit non-zero with the error code `RULES_ENGINE_MISMATCH` before running any engine, and the message SHALL name each differing engine with its recorded and bundled versions. + +When a recorded engine is older than the bundled one, the message SHALL direct the reader to the ledger walk (`update`) followed by `update --rules`, which applies any rule migrations and records the bundled engines. When a recorded engine is newer, the message SHALL direct the reader to the CLI the project recorded, since the rules were reconciled by a newer CLI than this one. + +An engine the manifest does not record, and an engine with no rules in the project, SHALL NOT cause a refusal: a project that never recorded a reconciliation has no claim to compare against, and an engine with no rules has nothing that could run wrongly. + +#### Scenario: Rules behind the bundled engine are refused + +- **WHEN** `rules.engines.sg` is `0.45.3`, this CLI bundles ast-grep `0.51.0`, and the project has an ast-grep rule +- **THEN** `check` SHALL exit non-zero with `RULES_ENGINE_MISMATCH` and run no engine +- **AND** the message SHALL name ast-grep `0.45.3` and `0.51.0` and `update --rules` + +#### Scenario: Rules ahead of the bundled engine are refused + +- **WHEN** `rules.engines.vale` is newer than the Vale this CLI bundles and the project has a Vale rule +- **THEN** `verify` SHALL exit non-zero with `RULES_ENGINE_MISMATCH` +- **AND** the message SHALL say the rules were reconciled by a newer CLI + +#### Scenario: An engine without rules is not compared + +- **WHEN** `rules.engines.vale` differs from the bundled Vale and the project has no Vale rules +- **THEN** `check` SHALL NOT refuse on account of Vale + +#### Scenario: A project that never recorded engines is not gated + +- **WHEN** the manifest has no `rules.engines` +- **THEN** `check` and `verify` SHALL run as before + +#### Scenario: Reconciling clears the refusal + +- **WHEN** `check` was refused because the rules were behind +- **AND** the user runs `update --rules` +- **THEN** a following `check` SHALL NOT be refused by this gate + +### Requirement: The version mismatch override has an environment form + +`--allow-version-mismatches` SHALL skip the version-pin gate and the engine-version gate as well as the scaffold-version refusal it already skips. The environment variable `TASKLESS_ALLOW_VERSION_MISMATCHES`, set to `1` or `true`, SHALL have the same effect as the flag, so a script that runs a deliberately unpinned build can set it once. + +#### Scenario: The flag skips both new gates + +- **WHEN** the running version does not match the recorded version, the recorded engines differ from the bundled ones, and the user runs `check --allow-version-mismatches` +- **THEN** `check` SHALL run + +#### Scenario: The environment variable is equivalent + +- **WHEN** `TASKLESS_ALLOW_VERSION_MISMATCHES=1` is set and the versions do not match +- **THEN** the CLI SHALL behave as though `--allow-version-mismatches` were passed From b433325636d2262afc8c5ec230f8a263b37ea48c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 00:41:51 -0700 Subject: [PATCH 05/10] docs(openspec): propose npx-install-only --- .../changes/npx-install-only/.openspec.yaml | 2 ++ openspec/changes/npx-install-only/design.md | 15 ++++++++++ openspec/changes/npx-install-only/proposal.md | 29 +++++++++++++++++++ .../npx-install-only/specs/cli/spec.md | 22 ++++++++++++++ openspec/changes/npx-install-only/tasks.md | 11 +++++++ 5 files changed, 79 insertions(+) create mode 100644 openspec/changes/npx-install-only/.openspec.yaml create mode 100644 openspec/changes/npx-install-only/design.md create mode 100644 openspec/changes/npx-install-only/proposal.md create mode 100644 openspec/changes/npx-install-only/specs/cli/spec.md create mode 100644 openspec/changes/npx-install-only/tasks.md diff --git a/openspec/changes/npx-install-only/.openspec.yaml b/openspec/changes/npx-install-only/.openspec.yaml new file mode 100644 index 00000000..dc36fb1b --- /dev/null +++ b/openspec/changes/npx-install-only/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/npx-install-only/design.md b/openspec/changes/npx-install-only/design.md new file mode 100644 index 00000000..e3af9f80 --- /dev/null +++ b/openspec/changes/npx-install-only/design.md @@ -0,0 +1,15 @@ +## Context + +The version-pin gate leaves npx exactly one legitimate use in a project: installing or upgrading. The installed skill and the recipes already name the launcher. Docs, one Vale rule, and two lines of CLI output did not. + +## Decisions + +- **The Vale rule lists subcommands rather than matching "any word but init".** A negative lookahead on the next word flags prose such as "run npx @taskless/cli to install". Listing every subcommand except `init` is exact, and a new subcommand is a one-line addition. +- **Flags stay allowed.** `npx @taskless/cli-nightly@latest --version` is how CONTRIBUTING checks a nightly. `--version` is intercepted before the gate, so it never conflicts. +- **The upgrade trailer uses `buildLauncherInvocation()`, falling back to `getCliPrefix()`.** `init` just wrote that launcher, and it runs the version the project now records. A `self` build has no launcher and keeps its path. +- **The bare notice checks the file, not the build alone.** The bare invocation is exempt from the gate and runs in projects that were never installed, so it names the launcher only when `.taskless/bin/cli.mjs` exists under the target directory. +- **Error remedies keep `getCliPrefix()`.** A remedy repeats how the person ran the CLI. Under the gate, any npx run that gets far enough to print one is already the recorded version, so the remedy runs. Changing roughly twenty call sites is not worth it for no behavioural difference. + +## Risks / Trade-offs + +- The Vale rule's subcommand list can drift from the CLI. A missed subcommand is a false negative in docs, not a refusal. diff --git a/openspec/changes/npx-install-only/proposal.md b/openspec/changes/npx-install-only/proposal.md new file mode 100644 index 00000000..d1072d79 --- /dev/null +++ b/openspec/changes/npx-install-only/proposal.md @@ -0,0 +1,29 @@ +## Why + +`cli-version-pin-gate` made every CLI other than the recorded one refuse to run in a project, apart from `init` and a few diagnostics. What we still tell people to run did not catch up. The READMEs show `npx @taskless/cli check`. The `docs-npx-cli` Vale rule actively rewrites docs _toward_ `npx @taskless/cli `. `init` run through npx ends by telling its reader to run `npx @taskless/cli@ update`. Each of those is a CLI the gate now refuses, or one that only works while npx happens to resolve the recorded version. + +npx has one remaining job: installing or upgrading, through the wizard (bare) or `init`. Every other command goes through the project launcher. + +## What Changes + +- The `docs-npx-cli` Vale rule changes direction. In a README or `dotagents.md`, an npx, `pnpm dlx`, `yarn dlx`, or `bunx` run of the CLI is an error unless it is bare or `init`. `pnpm cli` stays banned in READMEs. Fixtures are rewritten to match. +- The root, package, and example READMEs run every command other than the install through `./.taskless/bin/cli.mjs`. +- `init`'s upgrade trailer names the launcher it just wrote for `update`, not the npx invocation that ran it. +- The bare, non-interactive notice sends its reader to the launcher for recipes when the project has one. The scripted-install line keeps the npx invocation. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli`: follow-up commands printed by an install name the project launcher. + +## Impact + +- `.taskless/rules/vale/docs-npx-cli/`, `README.md`, `packages/cli/README.md`, `example/README.md`, `CONTRIBUTING.md`. +- `packages/cli/src/commands/init.ts`, `packages/cli/src/index.ts`, `packages/cli/src/install/upgrade-trailer.ts` (doc comment). +- This repo's own `.taskless/` still records 0.11.2 and is moved in a separate PR; its README is outside the rule's scope. +- Delivery shape: **single PR**, riding on `cli-version-pin-gate`'s PR. It finishes the same decision and is a small diff. diff --git a/openspec/changes/npx-install-only/specs/cli/spec.md b/openspec/changes/npx-install-only/specs/cli/spec.md new file mode 100644 index 00000000..5aadda42 --- /dev/null +++ b/openspec/changes/npx-install-only/specs/cli/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Follow-up commands after an install name the project launcher + +When the CLI prints a command for its reader to run next in an installed project, other than `init`, it SHALL name the project launcher (`./.taskless/bin/cli.mjs`, or `cli-nightly.mjs` for the nightly) rather than an npx or `pnpm dlx` invocation. npx is for installing and upgrading only. A build with no launcher SHALL name its own invocation. + +#### Scenario: The upgrade trailer names the launcher + +- **WHEN** `npx @taskless/cli@0.14.0 init` runs in a project that recorded `0.13.0` +- **THEN** the trailer SHALL say to run `./.taskless/bin/cli.mjs update` +- **AND** it SHALL NOT say to run `npx @taskless/cli@0.14.0 update` + +#### Scenario: The bare notice sends recipes to the launcher + +- **WHEN** the CLI runs with no subcommand, outside a terminal, in a project that has `.taskless/bin/cli.mjs` +- **THEN** the notice SHALL say to run `./.taskless/bin/cli.mjs agent` for recipes +- **AND** the scripted-install line SHALL still name the invocation that ran it, followed by `init` + +#### Scenario: A directory with no launcher keeps the running invocation + +- **WHEN** the CLI runs with no subcommand, outside a terminal, in a directory with no launcher +- **THEN** the notice SHALL name the running invocation for both `init` and `agent` diff --git a/openspec/changes/npx-install-only/tasks.md b/openspec/changes/npx-install-only/tasks.md new file mode 100644 index 00000000..5d53ba70 --- /dev/null +++ b/openspec/changes/npx-install-only/tasks.md @@ -0,0 +1,11 @@ +## 1. Docs and the Vale rule + +- [x] 1.1 Rewrite `docs-npx-cli` to refuse npx/dlx runs of any subcommand but `init`, keep the `pnpm cli` ban, and extend its scope to `dotagents.md` +- [x] 1.2 Rewrite its pass and fail fixtures, and confirm `verify` passes +- [x] 1.3 Move `README.md`, `packages/cli/README.md`, and `example/README.md` to the launcher, and update the rule's description in `README.md` and `CONTRIBUTING.md` + +## 2. CLI output + +- [x] 2.1 `init`'s upgrade trailer names `buildLauncherInvocation()` for `update` +- [x] 2.2 The bare non-interactive notice names the launcher for recipes when the project has one +- [x] 2.3 Tests: the trailer names `./.taskless/bin/cli.mjs update`; the bare notice names the launcher when one exists From 3c43baf7f3973ff0c3c1cfb3246b8d28efaa3a04 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 00:41:52 -0700 Subject: [PATCH 06/10] docs: run every command but the install through the project launcher The docs-npx-cli Vale rule now refuses an npx or dlx run of any subcommand but init, instead of rewriting docs toward npx @taskless/cli . --- .../vale/docs-npx-cli/.tests/fail/README.md | 6 +++-- .../vale/docs-npx-cli/.tests/pass/README.md | 7 ++++-- .taskless/rules/vale/docs-npx-cli/.vale.ini | 16 ++++++++++++-- .../rules/vale/docs-npx-cli/docs-npx-cli.yml | 13 ++++++----- CONTRIBUTING.md | 6 ++--- README.md | 18 ++++++++++----- example/README.md | 14 +++++++----- packages/cli/README.md | 22 +++++++++---------- 8 files changed, 66 insertions(+), 36 deletions(-) diff --git a/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md b/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md index 7a5c9e99..ecba4512 100644 --- a/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md +++ b/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md @@ -1,13 +1,15 @@ # Getting started -Run pnpm dlx @taskless/cli to install Taskless into this project. +Run npx @taskless/cli check to scan the project. -The inline form `pnpm dlx @taskless/cli@latest info` is a violation too: +The inline form `npx @taskless/cli@latest info` is a violation too: commands in docs are almost always in code spans, so the rule has to see them. ```bash pnpm dlx @taskless/cli@latest check +npx @taskless/cli-nightly@0.13.0 agent route +yarn dlx @taskless/cli rule list ``` You can also invoke pnpm cli info to check the version. diff --git a/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md index fb323cfc..9e34a2da 100644 --- a/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md +++ b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md @@ -2,10 +2,13 @@ Run npx @taskless/cli to install Taskless into this project. -The inline form `npx @taskless/cli@latest info` is correct. +For a scripted install or upgrade, `npx @taskless/cli@latest init` runs the +same install without prompts, and so does `pnpm dlx @taskless/cli init`. ```bash -npx @taskless/cli@latest check +./.taskless/bin/cli.mjs check +node .taskless/bin/cli.mjs check --json +npx @taskless/cli-nightly@latest --version ``` Other package managers are fine for unrelated work, such as pnpm install diff --git a/.taskless/rules/vale/docs-npx-cli/.vale.ini b/.taskless/rules/vale/docs-npx-cli/.vale.ini index c2b2de10..86c36bfa 100644 --- a/.taskless/rules/vale/docs-npx-cli/.vale.ini +++ b/.taskless/rules/vale/docs-npx-cli/.vale.ini @@ -1,5 +1,12 @@ -# READMEs are read by external consumers, who do not have this repo's scripts. -# AGENTS.md and .conventions/ deliberately document the local `pnpm cli` path. +# A project runs one CLI: the version `.taskless/taskless.json` records, through +# the launcher `init` writes. Any other version refuses every command but a few, +# so a doc that tells its reader to run `npx @taskless/cli check` is telling +# them to run a CLI that will refuse. npx and pnpm dlx appear in docs only to +# install or upgrade, bare (the wizard) or with `init`. +# +# READMEs are read by people who do not have this repo checked out, so they +# also never name the local `pnpm cli` script. AGENTS.md and .conventions/ +# deliberately document it. # # There is no per-case escape hatch for this rule, and that is a property of its # scope rather than an oversight. `scope: [raw, ...]` in the rule is what reaches @@ -14,6 +21,11 @@ tskl) rule = docs-npx-cli docs-npx-cli.docs-npx-cli = YES +# Read from outside the team exactly as a README is. +[dotagents.md] +tskl) rule = docs-npx-cli +docs-npx-cli.docs-npx-cli = YES + # Test fixtures are inputs to the CLI's own suite, not documentation. [**/test/fixtures/**/README.md] tskl) rule = docs-npx-cli diff --git a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml index a8f54022..2d55fd3f 100644 --- a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml +++ b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml @@ -1,8 +1,11 @@ -extends: substitution -message: "Use '%s' instead of '%s' — docs use the normalized invocation for external consumers" +extends: existence +message: "Run '%s' through the project launcher, ./.taskless/bin/cli.mjs. npx and pnpm dlx only install or upgrade: bare, or with init" level: error ignorecase: false +nonword: true scope: [raw, code, text] -swap: - 'pnpm dlx @taskless/cli': npx @taskless/cli - 'pnpm cli': npx @taskless/cli +tokens: + # Every subcommand but `init`, listed rather than matched as "any word that + # is not init", so prose such as "run npx @taskless/cli to install" passes. + - '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|auth|check|demo|detect|feedback|help|info|onboard|report|rule|share|test|update|validate|verify)\b' + - '\bpnpm cli\b' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a20e7a7f..51574918 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,9 @@ Thanks for working on Taskless. This file sits outside every Vale scope in `.taskless/rules/vale/`, which is -deliberate. The `docs-npx-cli` rule rewrites `pnpm cli` and -`pnpm dlx @taskless/cli` to the published `npx @taskless/cli` form, at error -level, with a `raw` scope that reaches inside fenced code blocks and cannot be +deliberate. The `docs-npx-cli` rule refuses `pnpm cli`, and any npx or +`pnpm dlx` run of the CLI other than an install or an upgrade, at error level, +with a `raw` scope that reaches inside fenced code blocks and cannot be suppressed with an in-file directive. That rule is right for a README, which is read by people who do not have this repository checked out. It makes a contributor guide impossible to write, because a contributor guide has to name diff --git a/README.md b/README.md index 17269c31..15448fe3 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,22 @@ scripts/ ## Skill -Taskless ships a **single consolidated skill** (`taskless`). The skill body is a small router; per-task instructions live behind `npx @taskless/cli agent ` and are fetched on demand. It handles any Taskless action (create rule, improve, delete, check, auth, CI) by fetching the canonical recipe for the user's intent and following it. +Taskless ships a **single consolidated skill** (`taskless`). The skill body is a small router; per-task instructions live behind `./.taskless/bin/cli.mjs agent ` (the project launcher `init` writes) and are fetched on demand. It handles any Taskless action (create rule, improve, delete, check, auth, CI) by fetching the canonical recipe for the user's intent and following it. Available `taskless agent` topics: `route`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, `create-remote-rule`, `improve-rule`, `delete-rule`, `check`, `auth`, `ci`, `info`, `init`, `update`. Run `taskless agent` with no topic for the index. Append `--anonymous` for the local-only flow on improve. ## CLI -The `@taskless/cli` package provides a CLI agent for Taskless workflows. It's recommended to always call the `latest` tag unless you know you need a specific version: +The `@taskless/cli` package provides a CLI agent for Taskless workflows. Install or upgrade a project with the `latest` tag: ```bash -npx @taskless/cli@latest info +npx @taskless/cli@latest init +``` + +That records the CLI version in `.taskless/taskless.json` and writes a launcher that runs exactly that version. Every other command goes through the launcher, since any other version of the CLI refuses to run in the project: + +```bash +./.taskless/bin/cli.mjs info ``` ## Local development @@ -64,8 +70,10 @@ contributors and agents at while they are working in this repo. Nothing rebuilds `dist/` for you, so run `pnpm build` first when you want current behavior. This section names the script rather than spelling out its shell invocation, because -the `docs-npx-cli` rule holds every command in a README to the published -`npx @taskless/cli` form for readers who do not have this repo checked out. +the `docs-npx-cli` rule keeps the local script out of every README, which is read +by people who do not have this repo checked out. The same rule allows npx and +`pnpm dlx` only to install or upgrade, and sends every other command through the +project launcher. ## Releasing taskless/cli diff --git a/example/README.md b/example/README.md index b40292ed..7761a671 100644 --- a/example/README.md +++ b/example/README.md @@ -51,7 +51,7 @@ They're build output. ## What `check` reports ``` -$ npx @taskless/cli check +$ ./.taskless/bin/cli.mjs check example.cjs:7:10 error[no-eval] Avoid eval. It executes whatever string it's handed. @@ -72,8 +72,10 @@ severity, so this run exits 1 on the `error`. ## Running it yourself, in this repo -`npx @taskless/cli` fetches the published CLI. To run the one in this -checkout, build it first and then call it from here: +The commands on this page run `./.taskless/bin/cli.mjs`, the launcher `init` +writes into a real install, which runs the CLI version the project records. +This example ships without one. To run the CLI in this checkout, build it first +and then call it from here: ``` pnpm --filter @taskless/cli build # from the repo root @@ -81,7 +83,7 @@ cd example ../packages/cli/dist/index.js check ``` -Substitute that path for `npx @taskless/cli` in every command below. +Substitute that path for `./.taskless/bin/cli.mjs` in every command on this page. Note that `check` writes the two assembled configs into `.taskless/`, so expect them to appear after the first run. Both are gitignored. @@ -90,8 +92,8 @@ expect them to appear after the first run. Both are gitignored. `check` runs rules against your code. Two other commands run against the rules: ``` -$ npx @taskless/cli validate # are these rules well-formed? -$ npx @taskless/cli verify # do they fire where they should, and only there? +$ ./.taskless/bin/cli.mjs validate # are these rules well-formed? +$ ./.taskless/bin/cli.mjs verify # do they fire where they should, and only there? ``` Both take a path: a rule directory, an engine directory, or nothing at all for diff --git a/packages/cli/README.md b/packages/cli/README.md index 244c64db..c8b01c37 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -23,16 +23,16 @@ without prompts, in a terminal or not: npx @taskless/cli init ``` -New to Taskless? Run `npx @taskless/cli onboard` after installing. It walks your -agent through your codebase and suggests a starter set of rules. - `init` records its version in `.taskless/taskless.json` and writes a launcher, `./.taskless/bin/cli.mjs`, that runs exactly that version. Any other version of the CLI refuses every command except `init`, `info`, `auth`, and `feedback` with `CLI_VERSION_MISMATCH`, because a project's rules are reconciled against -the CLI it records. So once a project is installed, run commands through the -launcher, and upgrade by running `init` with the newer CLI. The `npx` examples -on this page assume npx resolves the version the project records. +the CLI it records. So npx is only for installing and upgrading: once a project +is installed, run every command through the launcher, and upgrade by running +`init` with the newer CLI. + +New to Taskless? Run `./.taskless/bin/cli.mjs onboard` after installing. It +walks your agent through your codebase and suggests a starter set of rules. ## How to Use via Agents @@ -41,8 +41,8 @@ agent fetches the canonical recipe for whatever you asked for, then follows it. Ask in plain language: "write a taskless rule that bans console.log", "add taskless to CI", "run taskless check". You rarely need to run the CLI yourself. -To see what the agent sees, run `npx @taskless/cli agent` for the topic index, or -`npx @taskless/cli agent ` for a full recipe. +To see what the agent sees, run `./.taskless/bin/cli.mjs agent` for the topic +index, or `./.taskless/bin/cli.mjs agent ` for a full recipe. ## Taskless Check (CI and Constraints) @@ -51,9 +51,9 @@ error-severity matched and `1` when something did, so it drops straight into a pipeline: ```bash -npx @taskless/cli check # scan everything -npx @taskless/cli check $(git diff --name-only main...HEAD) # PR-only scan -npx @taskless/cli check --json # machine-readable +./.taskless/bin/cli.mjs check # scan everything +./.taskless/bin/cli.mjs check $(git diff --name-only main...HEAD) # PR-only scan +./.taskless/bin/cli.mjs check --json # machine-readable ``` Paths that no longer exist are dropped silently, so raw `git diff` output can be From 5306494873241ade2fd0cfd5e539204a7e06ee23 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 00:41:53 -0700 Subject: [PATCH 07/10] feat(cli): name the project launcher for follow-up commands, not npx init's upgrade trailer and the bare non-interactive notice sent readers to npx for update and agent, a CLI the version-pin gate refuses. --- .changeset/cli-version-pin-gate.md | 2 +- packages/cli/src/commands/init.ts | 9 +++++---- packages/cli/src/index.ts | 12 +++++++++++- packages/cli/src/install/upgrade-trailer.ts | 2 +- packages/cli/test/cli.test.ts | 19 ++++++++++++++++++- packages/cli/test/init-no-interactive.test.ts | 4 +++- 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.changeset/cli-version-pin-gate.md b/.changeset/cli-version-pin-gate.md index 9254ad2c..84774065 100644 --- a/.changeset/cli-version-pin-gate.md +++ b/.changeset/cli-version-pin-gate.md @@ -7,4 +7,4 @@ The CLI now runs only as the version a project records, and only on rules reconc - **A CLI that is not the version `.taskless/taskless.json` records refuses every command except `init`, `info`, `auth`, and `feedback`**, with `CLI_VERSION_MISMATCH`. That covers npx, `pnpm dlx`, a `devDependencies` install, and any other way of starting it. Run commands through the project launcher, `./.taskless/bin/cli.mjs`, which runs the recorded version, and upgrade by running `init` with the newer CLI. If CI runs a pinned CLI, move the pin in the same commit as the upgrade. - **`check` and `verify` refuse rules reconciled against a different ast-grep or Vale**, with `RULES_ENGINE_MISMATCH`, when `rules.engines` names an engine version this CLI does not bundle and the project has rules for that engine. Until now they ran the new engine against rules nobody had migrated. A rule still using a renamed kind could leave `check` reporting no ast-grep findings at all. When the rules are behind, run `update` and then `update --rules`, which applies the rule rewrites and records the bundled engines. A project that never recorded its engines is not affected. - `--allow-version-mismatches` skips both refusals, and `TASKLESS_ALLOW_VERSION_MISMATCHES=1` does the same from the environment. -- The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. +- npx is now only for installing and upgrading. The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. `init`'s closing line says to run `./.taskless/bin/cli.mjs update` rather than the npx invocation that ran it, and the bare, non-interactive notice sends its reader to the launcher for recipes when the project has one. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index c90a7694..20149c81 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -187,10 +187,11 @@ export const initCommand = defineCommand({ migrated: result.migrated !== undefined, previousCliVersion: result.previousCliVersion, cliVersion: result.cliVersion, - // A message a person reads, so the launcher they used, not the - // build's fixed spelling: the same convention as every other remedy - // (`auth login`, `rule delete`). - invocation: getCliPrefix(), + // The launcher this install just wrote, not the npx that ran it: + // npx is for installing and upgrading, and `update` runs as the + // version the project now records. A build with no launcher names + // the invocation the person used. + invocation: buildLauncherInvocation() ?? getCliPrefix(), }); if (upgradeTrailer !== undefined) { console.log(upgradeTrailer); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d6ae3b0b..db5b6c0e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import process from "node:process"; import { defineCommand, runCommand, showUsage } from "citty"; @@ -34,6 +36,7 @@ import { shouldLaunchWizard } from "./util/interactive"; import { showResolvedUsage } from "./util/help"; import { getCliPrefix } from "./util/package-manager"; import { CLIError } from "./util/cli-error"; +import { buildLauncherInvocation } from "./install/launcher"; import { requireRecordedVersion } from "./install/version-gate"; import { writeJsonError } from "./types/errors"; @@ -151,11 +154,18 @@ const main = defineCommand({ } const prefix = getCliPrefix(); + // Recipes come from the version the project records, so a project with a + // launcher is sent to it. Only the install goes through npx. + const launcher = buildLauncherInvocation(); + const recipes = + launcher !== undefined && existsSync(join(resolveCwd(rawArgs), launcher)) + ? launcher + : prefix; console.error( "Taskless CLI — non-interactive context detected.\n" + " For interactive install, run from a terminal.\n" + ` For scripted install, run \`${prefix} init\`.\n` + - ` For agent recipes, run \`${prefix} agent\` (no args) for the topic index.\n` + ` For agent recipes, run \`${recipes} agent\` (no args) for the topic index.\n` ); // Forward the parent's rawArgs (e.g. `-d `) so the agent command // doesn't mis-parse them as positional topic names. diff --git a/packages/cli/src/install/upgrade-trailer.ts b/packages/cli/src/install/upgrade-trailer.ts index ba607b68..6316bcbd 100644 --- a/packages/cli/src/install/upgrade-trailer.ts +++ b/packages/cli/src/install/upgrade-trailer.ts @@ -19,7 +19,7 @@ export interface UpgradeTrailerInput { previousCliVersion?: string; /** The version this run recorded. */ cliVersion: string; - /** The invocation to print in front of `update`, e.g. `npx @taskless/cli`. */ + /** The invocation to print in front of `update`, e.g. `./.taskless/bin/cli.mjs`. */ invocation: string; } diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 47e45695..b12d3e07 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile, mkdtemp, rm, mkdir } from "node:fs/promises"; +import { readFile, mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; @@ -110,6 +110,23 @@ describe("cli", () => { expect(stderr).toContain("non-interactive context detected"); } ); + + it("sends recipes to the project launcher when the project has one", async () => { + await mkdir(join(temporaryDirectory, ".taskless", "bin"), { + recursive: true, + }); + await writeFile( + join(temporaryDirectory, ".taskless", "bin", "cli.mjs"), + "" + ); + const { stderr } = await execFileAsync("node", [ + binPath, + "-d", + temporaryDirectory, + ]); + expect(stderr).toContain("run `./.taskless/bin/cli.mjs agent`"); + expect(stderr).not.toMatch(/run `(?:npx|pnpm dlx|yarn dlx) \S+ agent`/); + }); }); describe("init", () => { diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 99fe50a2..b19485c6 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -256,7 +256,9 @@ describe("taskless init (the batch install)", () => { expect(stdout).toContain(".taskless/"); expect(stdout).toContain(".claude/"); expect(stdout).toContain("moved from 0.0.1-previous to"); - expect(stdout).toMatch(/Run `.* update`/); + // The launcher this install wrote, never the npx that ran it: `update` + // must run as the version the project now records. + expect(stdout).toContain("Run `./.taskless/bin/cli.mjs update`"); // Order: upgrade trailer, then the reload banner (the version moved, so // it prints), then the onboarding line last. From a879650cdb3ee4b342d4dda4b3c955e31fb94d19 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 00:42:03 -0700 Subject: [PATCH 08/10] docs(openspec): archive npx-install-only --- .../.openspec.yaml | 0 .../2026-10-21-npx-install-only}/design.md | 0 .../2026-10-21-npx-install-only}/proposal.md | 0 .../specs/cli/spec.md | 0 .../2026-10-21-npx-install-only}/tasks.md | 0 openspec/specs/cli/spec.md | 21 +++++++++++++++++++ 6 files changed, 21 insertions(+) rename openspec/changes/{npx-install-only => archive/2026-10-21-npx-install-only}/.openspec.yaml (100%) rename openspec/changes/{npx-install-only => archive/2026-10-21-npx-install-only}/design.md (100%) rename openspec/changes/{npx-install-only => archive/2026-10-21-npx-install-only}/proposal.md (100%) rename openspec/changes/{npx-install-only => archive/2026-10-21-npx-install-only}/specs/cli/spec.md (100%) rename openspec/changes/{npx-install-only => archive/2026-10-21-npx-install-only}/tasks.md (100%) diff --git a/openspec/changes/npx-install-only/.openspec.yaml b/openspec/changes/archive/2026-10-21-npx-install-only/.openspec.yaml similarity index 100% rename from openspec/changes/npx-install-only/.openspec.yaml rename to openspec/changes/archive/2026-10-21-npx-install-only/.openspec.yaml diff --git a/openspec/changes/npx-install-only/design.md b/openspec/changes/archive/2026-10-21-npx-install-only/design.md similarity index 100% rename from openspec/changes/npx-install-only/design.md rename to openspec/changes/archive/2026-10-21-npx-install-only/design.md diff --git a/openspec/changes/npx-install-only/proposal.md b/openspec/changes/archive/2026-10-21-npx-install-only/proposal.md similarity index 100% rename from openspec/changes/npx-install-only/proposal.md rename to openspec/changes/archive/2026-10-21-npx-install-only/proposal.md diff --git a/openspec/changes/npx-install-only/specs/cli/spec.md b/openspec/changes/archive/2026-10-21-npx-install-only/specs/cli/spec.md similarity index 100% rename from openspec/changes/npx-install-only/specs/cli/spec.md rename to openspec/changes/archive/2026-10-21-npx-install-only/specs/cli/spec.md diff --git a/openspec/changes/npx-install-only/tasks.md b/openspec/changes/archive/2026-10-21-npx-install-only/tasks.md similarity index 100% rename from openspec/changes/npx-install-only/tasks.md rename to openspec/changes/archive/2026-10-21-npx-install-only/tasks.md diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 6fe1a237..74c78822 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -667,3 +667,24 @@ An engine the manifest does not record, and an engine with no rules in the proje - **WHEN** `TASKLESS_ALLOW_VERSION_MISMATCHES=1` is set and the versions do not match - **THEN** the CLI SHALL behave as though `--allow-version-mismatches` were passed + +### Requirement: Follow-up commands after an install name the project launcher + +When the CLI prints a command for its reader to run next in an installed project, other than `init`, it SHALL name the project launcher (`./.taskless/bin/cli.mjs`, or `cli-nightly.mjs` for the nightly) rather than an npx or `pnpm dlx` invocation. npx is for installing and upgrading only. A build with no launcher SHALL name its own invocation. + +#### Scenario: The upgrade trailer names the launcher + +- **WHEN** `npx @taskless/cli@0.14.0 init` runs in a project that recorded `0.13.0` +- **THEN** the trailer SHALL say to run `./.taskless/bin/cli.mjs update` +- **AND** it SHALL NOT say to run `npx @taskless/cli@0.14.0 update` + +#### Scenario: The bare notice sends recipes to the launcher + +- **WHEN** the CLI runs with no subcommand, outside a terminal, in a project that has `.taskless/bin/cli.mjs` +- **THEN** the notice SHALL say to run `./.taskless/bin/cli.mjs agent` for recipes +- **AND** the scripted-install line SHALL still name the invocation that ran it, followed by `init` + +#### Scenario: A directory with no launcher keeps the running invocation + +- **WHEN** the CLI runs with no subcommand, outside a terminal, in a directory with no launcher +- **THEN** the notice SHALL name the running invocation for both `init` and `agent` From ca864ef1b4d6a994d54f815bb8df29b4732e8cd6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 01:19:19 -0700 Subject: [PATCH 09/10] docs: allow npx for auth, and say in the auth recipe that it runs on any version A login is a per-repo token, not a version, so auth is exempt from the version-pin gate. docs-npx-cli now allows npx and dlx runs of auth. --- .changeset/cli-version-pin-gate.md | 2 +- .../rules/vale/docs-npx-cli/.tests/pass/README.md | 2 ++ .taskless/rules/vale/docs-npx-cli/.vale.ini | 3 ++- .taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml | 10 ++++++---- packages/cli/README.md | 7 ++++--- packages/cli/src/agent/auth.md | 11 ++++++++++- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.changeset/cli-version-pin-gate.md b/.changeset/cli-version-pin-gate.md index 84774065..4df6fd3c 100644 --- a/.changeset/cli-version-pin-gate.md +++ b/.changeset/cli-version-pin-gate.md @@ -7,4 +7,4 @@ The CLI now runs only as the version a project records, and only on rules reconc - **A CLI that is not the version `.taskless/taskless.json` records refuses every command except `init`, `info`, `auth`, and `feedback`**, with `CLI_VERSION_MISMATCH`. That covers npx, `pnpm dlx`, a `devDependencies` install, and any other way of starting it. Run commands through the project launcher, `./.taskless/bin/cli.mjs`, which runs the recorded version, and upgrade by running `init` with the newer CLI. If CI runs a pinned CLI, move the pin in the same commit as the upgrade. - **`check` and `verify` refuse rules reconciled against a different ast-grep or Vale**, with `RULES_ENGINE_MISMATCH`, when `rules.engines` names an engine version this CLI does not bundle and the project has rules for that engine. Until now they ran the new engine against rules nobody had migrated. A rule still using a renamed kind could leave `check` reporting no ast-grep findings at all. When the rules are behind, run `update` and then `update --rules`, which applies the rule rewrites and records the bundled engines. A project that never recorded its engines is not affected. - `--allow-version-mismatches` skips both refusals, and `TASKLESS_ALLOW_VERSION_MISMATCHES=1` does the same from the environment. -- npx is now only for installing and upgrading. The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. `init`'s closing line says to run `./.taskless/bin/cli.mjs update` rather than the npx invocation that ran it, and the bare, non-interactive notice sends its reader to the launcher for recipes when the project has one. +- npx is now only for installing, upgrading, and logging in (`auth` runs on any version). The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. `init`'s closing line says to run `./.taskless/bin/cli.mjs update` rather than the npx invocation that ran it, and the bare, non-interactive notice sends its reader to the launcher for recipes when the project has one. diff --git a/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md index 9e34a2da..740ad604 100644 --- a/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md +++ b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md @@ -5,6 +5,8 @@ Run npx @taskless/cli to install Taskless into this project. For a scripted install or upgrade, `npx @taskless/cli@latest init` runs the same install without prompts, and so does `pnpm dlx @taskless/cli init`. +Logging in works from any version, so `npx @taskless/cli auth login` is fine. + ```bash ./.taskless/bin/cli.mjs check node .taskless/bin/cli.mjs check --json diff --git a/.taskless/rules/vale/docs-npx-cli/.vale.ini b/.taskless/rules/vale/docs-npx-cli/.vale.ini index 86c36bfa..265c570a 100644 --- a/.taskless/rules/vale/docs-npx-cli/.vale.ini +++ b/.taskless/rules/vale/docs-npx-cli/.vale.ini @@ -2,7 +2,8 @@ # the launcher `init` writes. Any other version refuses every command but a few, # so a doc that tells its reader to run `npx @taskless/cli check` is telling # them to run a CLI that will refuse. npx and pnpm dlx appear in docs only to -# install or upgrade, bare (the wizard) or with `init`. +# install or upgrade, bare (the wizard) or with `init`, and to log in with +# `auth`, which runs on any version. # # READMEs are read by people who do not have this repo checked out, so they # also never name the local `pnpm cli` script. AGENTS.md and .conventions/ diff --git a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml index 2d55fd3f..0a66140a 100644 --- a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml +++ b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml @@ -1,11 +1,13 @@ extends: existence -message: "Run '%s' through the project launcher, ./.taskless/bin/cli.mjs. npx and pnpm dlx only install or upgrade: bare, or with init" +message: "Run '%s' through the project launcher, ./.taskless/bin/cli.mjs. npx and pnpm dlx only install, upgrade, or log in: bare, init, or auth" level: error ignorecase: false nonword: true scope: [raw, code, text] tokens: - # Every subcommand but `init`, listed rather than matched as "any word that - # is not init", so prose such as "run npx @taskless/cli to install" passes. - - '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|auth|check|demo|detect|feedback|help|info|onboard|report|rule|share|test|update|validate|verify)\b' + # Every subcommand but `init` and `auth`, listed rather than matched as "any + # word that is not init", so prose such as "run npx @taskless/cli to install" + # passes. `auth` is allowed because a login is a per-repo token, not a + # version: the CLI runs it on any version. + - '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|check|demo|detect|feedback|help|info|onboard|report|rule|share|test|update|validate|verify)\b' - '\bpnpm cli\b' diff --git a/packages/cli/README.md b/packages/cli/README.md index c8b01c37..0988395d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,9 +27,10 @@ npx @taskless/cli init `./.taskless/bin/cli.mjs`, that runs exactly that version. Any other version of the CLI refuses every command except `init`, `info`, `auth`, and `feedback` with `CLI_VERSION_MISMATCH`, because a project's rules are reconciled against -the CLI it records. So npx is only for installing and upgrading: once a project -is installed, run every command through the launcher, and upgrade by running -`init` with the newer CLI. +the CLI it records. So npx is only for installing, upgrading, and logging in +(`auth login` works from any version): once a project is installed, run every +other command through the launcher, and upgrade by running `init` with the +newer CLI. New to Taskless? Run `./.taskless/bin/cli.mjs onboard` after installing. It walks your agent through your codebase and suggests a starter set of rules. diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index 9ddf45e6..62076248 100644 --- a/packages/cli/src/agent/auth.md +++ b/packages/cli/src/agent/auth.md @@ -1,4 +1,4 @@ -# Topic: auth (CLI v%(CLI_VERSION)s / topic v3) +# Topic: auth (CLI v%(CLI_VERSION)s / topic v4) %(ONE_COMMAND_PER_CALL)s @@ -14,6 +14,15 @@ Manage Taskless authentication. Three branches: - `.taskless/` directory exists. - For login: the user has a browser to approve the device code. +`auth` runs on any version of the CLI. Most commands refuse with +`CLI_VERSION_MISMATCH` when the running CLI is not the version +`.taskless/taskless.json` records, but a login is a token saved in +`.taskless/.env.local.json`, not something a version owns, so `auth` +is exempt. Use the invocation below anyway. A person logging in by hand +can run `auth login` from any version, launcher or not. When another +command refused with `CLI_VERSION_MISMATCH`, logging in does not fix +it. Follow that message's own remedy. + ## Steps Pick the branch matching the user's intent. From fdd7b66d9c8634afaef8f2a7daad3e86cb8bcd5b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 21 Oct 2026 01:39:52 -0700 Subject: [PATCH 10/10] fix(docs-npx-cli): list only registered subcommands demo, help and test are not in commands/names.ts. --- .taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml index 0a66140a..120d3bba 100644 --- a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml +++ b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml @@ -5,9 +5,10 @@ ignorecase: false nonword: true scope: [raw, code, text] tokens: - # Every subcommand but `init` and `auth`, listed rather than matched as "any - # word that is not init", so prose such as "run npx @taskless/cli to install" - # passes. `auth` is allowed because a login is a per-repo token, not a - # version: the CLI runs it on any version. - - '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|check|demo|detect|feedback|help|info|onboard|report|rule|share|test|update|validate|verify)\b' + # Every subcommand in packages/cli/src/commands/names.ts but `init` and + # `auth`, listed rather than matched as "any word that is not init", so prose + # such as "run npx @taskless/cli to install" passes. `auth` is allowed + # because a login is a per-repo token, not a version: the CLI runs it on any + # version. + - '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|check|detect|feedback|info|onboard|report|rule|share|update|validate|verify)\b' - '\bpnpm cli\b'