From 9915555fcf71e144a84a7fb134722d7f5c163f6c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 15:41:20 -0700 Subject: [PATCH 01/15] docs(openspec): propose pinned-cli-wrapper A project-local launcher per package (.taskless/bin/cli.mjs, cli-nightly.mjs) that runs the version recorded in .taskless/taskless.json, with launcher permission rules, a global trust entry that covers it and folds duplicates, and a ci recipe that runs it with Node supplied by the job. --- .../changes/pinned-cli-wrapper/.openspec.yaml | 2 + openspec/changes/pinned-cli-wrapper/design.md | 66 ++++++++ .../changes/pinned-cli-wrapper/proposal.md | 37 +++++ .../specs/agent-global-trust/spec.md | 101 ++++++++++++ .../specs/cli-agent/spec.md | 41 +++++ .../pinned-cli-wrapper/specs/cli-init/spec.md | 153 ++++++++++++++++++ .../specs/cli-project-launcher/spec.md | 84 ++++++++++ openspec/changes/pinned-cli-wrapper/tasks.md | 43 +++++ 8 files changed, 527 insertions(+) create mode 100644 openspec/changes/pinned-cli-wrapper/.openspec.yaml create mode 100644 openspec/changes/pinned-cli-wrapper/design.md create mode 100644 openspec/changes/pinned-cli-wrapper/proposal.md create mode 100644 openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md create mode 100644 openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md create mode 100644 openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md create mode 100644 openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md create mode 100644 openspec/changes/pinned-cli-wrapper/tasks.md diff --git a/openspec/changes/pinned-cli-wrapper/.openspec.yaml b/openspec/changes/pinned-cli-wrapper/.openspec.yaml new file mode 100644 index 00000000..c1bc9027 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/pinned-cli-wrapper/design.md b/openspec/changes/pinned-cli-wrapper/design.md new file mode 100644 index 00000000..2e12f24d --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/design.md @@ -0,0 +1,66 @@ +## Context + +See proposal.md for why. The installed skill, project permission rules, and recipes all name the CLI as `npx @taskless/cli@` today. That string is baked into the build (`resolveCliInvocation`), rendered into the canonical skill, and detected again at `agent ` time (`detectCliInvocation`). Permissions are already scoped per package (release and nightly keep separate rules and separate global trust entries), and the launcher follows the same split. + +What a manual auto-mode probe showed on 2026-10-19 (one run per case, a single classifier sample each, so treat these as strong hints): + +| Case | Result | +| ---------------------------------------------------------------------- | ----------------------------- | +| `npx @taskless/cli@0.11.2 …`, no allowance | refused | +| `./.taskless/bin/cli.mjs …` (runs that npx inside), no allowance | allowed | +| `node .taskless/bin/cli.mjs …`, no allowance | refused as an external script | +| Either launcher spelling, with an allowance naming the launcher | allowed | +| The launcher, read first by the agent, with a suspicious comment added | refused | +| The launcher swapped to run another package, with the allowance | allowed | + +## Goals / Non-Goals + +**Goals:** + +- A launcher spelling that agents run and harnesses can allow by a fixed path, whatever the version. +- A CLI version CI reads from the project at run time, without a `package.json`. +- The release and a nightly installed side by side without either changing what the other runs. + +**Non-Goals:** + +- Supplying Node. A runner without Node cannot run either a launcher or npx; the `ci` recipe asks the job to bring Node. +- A container image, a pre-commit hook, or a GitHub Action. All are reasonable follow-ups for non-Node repositories, and none is needed for the launcher to work. +- Verifying the CLI package's integrity beyond what npm already does. `npx` pins the top-level package; its one runtime dependency (`tsx`) still resolves by range. +- Changing `pinned-cli.ts`. `package.json` pins still go stale and are still reported. + +## Decisions + +**The version is read when the launcher runs, not baked into it.** The launcher's bytes are then the same for every version, so an upgrade changes one line of `.taskless/taskless.json` and not an executable file, and the skill text no longer moves with the version. The alternative, writing the version into the launcher, gives a reviewer a self-describing diff, but every upgrade rewrites an executable that a harness allows by path, which is the file we most want to stay boring. + +**One version per package, under `install.launchers`.** `install.cliVersion` is overwritten by whichever build installed last, so a launcher reading it alone would make a nightly install move the release launcher. A map keyed by package name keeps each launcher independent. The launcher falls back to `install.cliVersion` when the map lacks its package and that version is its own (a `-` means nightly, the same rule `bumpTarget()` uses). An older CLI that rewrites the manifest drops fields it does not know, and the fallback keeps the release launcher working through that. + +**Agents run `./.taskless/bin/cli.mjs`; CI runs `node .taskless/bin/cli.mjs`.** The probe refused `node ` and allowed `./`, so the agent-facing spelling is the direct one. CI has no classifier, and there `node ` survives a checkout without the executable bit and a Windows runner that ignores shebangs. Claude Code on Windows runs Bash through Git Bash, which honours the shebang; that is a task to confirm, not an assumption to ship. + +**`.mjs`, not `.js` or no extension.** Whether a `.js` or extensionless file is ESM depends on the nearest `package.json` `"type"`, which is the user's. `.mjs` is ESM everywhere. The `#!` line is part of the language grammar (ES2023) and Node strips it. + +**The launcher runs `npx --yes`, with `shell` only on win32.** On Windows `npx` is `npx.cmd`, and Node refuses to spawn a `.cmd` without a shell. Arguments pass through cmd's quoting there, which matters for paths with spaces or metacharacters; the launcher quotes each argument for cmd before handing it over. Alternatives considered: installing once into a cache with npm and then running `node /dist/index.js` directly (no shell for user arguments, but a second code path and cache management, and the probe showed the classifier does not care what the launcher runs); fetching the tarball directly (cannot resolve the per-platform ast-grep and Vale packages that `platform-binary.ts` depends on). + +**The launcher announces itself through one environment variable.** `TASKLESS_LAUNCHER=./.taskless/bin/cli.mjs`. `detectCliInvocation` prefers it, so `agent ` renders recipes naming the launcher. The CLI accepts only the two known paths, so the variable cannot inject text into a recipe. + +**Launcher rules are added beside the invocation rules, not instead of them.** The pinned npx rule still covers stubs' recovery commands and anyone typing the npx form. The launcher rule carries no version, so it never goes stale and upgrades report no edit for it. + +**Global entry cleanup runs in `init`'s trust step, not in a migration.** Migrations change `.taskless/` and run on every write path, including `--no-interactive` and CI, with nobody there to confirm. The global entry lives in the user's own settings, which the spec only lets the wizard write, under one confirmation. An unattended edit there would also be refused by the classifier when an agent runs an upgrade. The trust step already runs on every `init`, first install or upgrade, so putting the fold there gives full cleanup on every upgrade a human confirms, and a warning on every one they do not. + +**`init` restores an edited launcher and says so.** The probe showed an allowance on the path trusts whatever the file contains. `init` cannot prevent a malicious edit landing in a repository, and the CLI cannot reliably check the launcher that started it, since a tampered launcher need not start the real CLI. Restoring on every install and naming it in the summary is the defence that fits; code review is the rest. + +## Risks / Trade-offs + +- [The classifier's judgment is sampled once per case] → The global entry names the launcher explicitly, so the decision does not rest on the unallowed case passing. +- [An agent whose shell is not at the project root cannot run `./.taskless/bin/cli.mjs`] → The skill already requires each Taskless call to start its own line from the project root; keep that wording and test it against the launcher. +- [A launcher comment can get an agent refused] → The spec forbids comments that frame the launcher as a way around a check; a test asserts it. +- [Someone edits the launcher in a pull request] → `init` restores it and reports it; review catches the rest. This is the same exposure as any script in the repository. +- [Cursor CLI's `Shell(...)` and Codex's `prefix_rule` may not match a relative path] → Verify each before writing; a harness that cannot match gets no launcher rule, recorded in the spec table. +- [Global entries written before this change read as stale] → The wizard already offers `REPLACE (global):` once; no new mechanism. + +## Migration Plan + +No scaffold migration. The first `init` from a version carrying this writes the launcher, records `install.launchers`, adds the launcher rules, and offers to replace the global entry. Projects that never re-run `init` keep working on npx exactly as before. Rolling back to an older CLI leaves the launcher in place; it keeps working through the `install.cliVersion` fallback until that version moves to a nightly. + +## Open Questions + +- Whether the `hooks` recipe should also switch to the launcher now or in a follow-up. It has the same shape as `ci` but writes into tools (husky, lefthook) whose own runners may not start at the project root. diff --git a/openspec/changes/pinned-cli-wrapper/proposal.md b/openspec/changes/pinned-cli-wrapper/proposal.md new file mode 100644 index 00000000..e5e38c12 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/proposal.md @@ -0,0 +1,37 @@ +## Why + +Every Taskless command an agent or CI runs is spelled `npx @taskless/cli@`. Claude Code's auto-mode classifier refuses that spelling as downloaded code, even with a project allow rule. It also puts the version into every installed command, so each release rewrites the skill. A manual probe on 2026-10-19 showed the same npx call made from a project-local script, `./.taskless/bin/cli.mjs`, ran with no allowance at all, and ran reliably once an allowance named the script. Running it as `node .taskless/bin/cli.mjs` was refused as an external script. A project-local launcher gives agents a spelling the classifier accepts, and gives CI in any language one fixed command whose version is read from the project at run time. + +## What Changes + +- **`init` installs a launcher per package.** The release writes `.taskless/bin/cli.mjs`, and a nightly writes `.taskless/bin/cli-nightly.mjs`. Each is a small Node script, written only by its own package, that reads its package's version from `.taskless/taskless.json` when it runs and runs that exact version through `npx`. Neither build touches the other's launcher, so testing a nightly beside the release works the same way their permissions already do. +- **The manifest records one version per package.** `install.cliVersion` keeps meaning "the build that last installed". A new `install.launchers` records the version each package's launcher runs, so a nightly install no longer changes what `cli.mjs` runs. +- **The skill names the launcher.** `%(TASKLESS_CLI)s` in the installed skill renders to `./.taskless/bin/cli.mjs` (or `cli-nightly.mjs`) instead of a pinned npx call. The rendered skill no longer changes on every release. Reference stubs' recovery commands keep the npx spelling, since they exist for when `.taskless/` is gone. +- **Recipes fetched through a launcher name the launcher.** The launcher tells the CLI it was the one that started it, and `agent ` renders `%(TASKLESS_CLI)s` as the launcher, so a recipe never sends an agent back to the npx spelling the classifier refuses. +- **Permissions cover the launcher.** Each harness's project rule set gains a rule for the installed package's launcher. Each package's global auto-mode entry also covers running its launcher from the project root. **BREAKING** for the current entry text: existing entries read as stale once, and the wizard offers to replace them. +- **Every install and upgrade folds duplicate global entries into one.** Today, when `autoMode.allow` holds more than one string under this build's label, the wizard replaces the first and leaves the rest, so a hand-pasted copy beside a wizard-written one stays forever. The wizard now leaves exactly one, inside the confirmation it already asks. `--no-interactive` still writes nothing outside the project and says how many duplicates the wizard would fold. +- **The `ci` recipe runs the launcher with `node`.** Generated CI calls `node .taskless/bin/cli.mjs check`, which needs Node on the runner but no `package.json`. The recipe tells the Taskless job to bring its own Node (a setup step, or a Node image), so a Python or Go repository can run it from a separate job. + +## Capabilities + +### New Capabilities + +- `cli-project-launcher`: the launcher scripts. Where they live and what they are called, where they read their version, how they run the CLI and pass its exit status back, and how they fail when no version is recorded. + +### Modified Capabilities + +- `cli-init`: installs the launcher, records per-package launcher versions, renders the skill's invocation to the launcher, and adds launcher rules to the project permission files; the definition of a Taskless rule widens to include the launcher. +- `agent-global-trust`: each package's entry also covers its launcher, and duplicate strings under a label read as stale and are folded into one on every install and upgrade. +- `cli-agent`: recipes fetched through a launcher name it, and the `ci` recipe generates a launcher call with Node supplied by the job. + +## Impact + +- New template for the launcher, written by `packages/cli/src/install/` alongside the canonical skill. +- `packages/cli/src/install/state.ts`, `filesystem/manifest.ts`: the `install.launchers` field. +- `packages/cli/src/install/canonical.ts`, `util/invocation.ts`: the skill renders to the launcher path; stubs keep npx. +- `packages/cli/src/util/package-manager.ts`, `commands/agent.ts`: launcher detection for recipe rendering. +- `packages/cli/src/install/permissions.ts`: launcher rules per harness and package mapping for the launcher filenames. +- `packages/cli/src/install/global-trust.ts`: entry text. +- `packages/cli/src/agent/ci.md`: generated commands. +- Tests for each of the above, including the entry-text and installed-skill snapshot tests. +- Delivery shape: **single PR**. The launcher, its permissions, and the invocation change are only useful together, and the diff should fit one review. diff --git a/openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md b/openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..8461f0e4 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: The global entry names no version and requires one + +Each package SHALL have its own entry: the release's begins with the label `Taskless CLI:` and names only `@taskless/cli`; the nightly's begins with the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. Neither label SHALL be a prefix of the other's entry. The released build SHALL use the release's entry, and every other build (nightly, `self`) the nightly's. Each entry SHALL state that running its package through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. Each entry SHALL also state that running its package's launcher from the project root (`./.taskless/bin/cli.mjs` for the release, `./.taskless/bin/cli-nightly.mjs` for the nightly) is allowed with any arguments, and that the launcher runs the exact version the project recorded for that package. It SHALL NOT name the other package's launcher. It SHALL NOT name a version, so an upgrade never makes it stale. Changing the entry to cover the launcher makes every entry written before it stale, which the wizard replaces as it replaces any stale entry. + +Each entry SHALL also state that the call stays covered when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file, and that every other command on that line is judged on its own. It SHALL NOT extend coverage to any command other than the pinned Taskless call. + +#### Scenario: The entry carries no version + +- **WHEN** a package's entry is written by any version of that package +- **THEN** its text SHALL be identical, and SHALL contain no version number other than the fixed example + +#### Scenario: Each entry names only its own package + +- **WHEN** the release's and the nightly's entries are read +- **THEN** the release's SHALL begin `Taskless CLI:` and SHALL NOT name `@taskless/cli-nightly` +- **AND** the nightly's SHALL begin `Taskless CLI nightly:` and name `@taskless/cli-nightly` + +#### Scenario: The entry states the pin requirement + +- **WHEN** the entry is read +- **THEN** it SHALL say a call is covered only when it names an exact version +- **AND** it SHALL say `@latest` is not covered + +#### Scenario: The entry covers the call inside shell plumbing + +- **WHEN** the entry is read +- **THEN** it SHALL say the call stays covered alongside a variable assignment, a directory change or creation, or a redirect of its output to a file +- **AND** it SHALL say every other command on the line is judged on its own + +#### Scenario: The entry covers its own launcher + +- **WHEN** the release's entry is read +- **THEN** it SHALL say running `./.taskless/bin/cli.mjs` from the project root is allowed +- **AND** it SHALL NOT name `cli-nightly.mjs` + +#### Scenario: An entry from before the launcher is stale + +- **WHEN** `autoMode.allow` holds the release's entry as written before this change +- **THEN** `init` SHALL report it as stale + +### Requirement: The global entry is detected by its label + +`init` SHALL find the entry by this build's label: a string in the harness's list beginning with it is this build's entry, whatever follows. A string beginning with the other package's label SHALL never be read, edited, or removed, so a release and a nightly used on one machine never replace each other's entry. The entry SHALL be `present` when exactly one string carries this build's label and its whole text equals the current entry, and a present entry SHALL never be rewritten. It SHALL be `stale` when any string carrying the label has other text, or when more than one string carries the label, even if one of them is current. A string without this build's label SHALL never be read as this build's entry, edited, or removed. + +A stale entry SHALL be replaced only where the wizard writes it, and the replacement SHALL leave exactly one string carrying the label: the first such string SHALL be replaced in place by the current entry, and every later string carrying the label SHALL be removed. Strings carrying the other package's label, and strings with no Taskless label, SHALL keep their text and their order. + +#### Scenario: A current entry is left alone + +- **WHEN** `autoMode.allow` holds the current entry text +- **THEN** `init` SHALL report the entry as present +- **AND** SHALL NOT edit the file + +#### Scenario: A reworded entry is left alone + +- **WHEN** `autoMode.allow` holds `"Taskless CLI: allowed with a pinned version"` +- **THEN** `init` SHALL report the entry as stale +- **AND** SHALL NOT edit the file, leaving the replacement to the wizard + +#### Scenario: An unlabelled entry is never Taskless's + +- **WHEN** `autoMode.allow` holds `"Internal Registry: ..."` and no `Taskless CLI:` entry +- **THEN** `init` SHALL report the entry as absent +- **AND** SHALL NOT treat `"Internal Registry: ..."` as stale + +#### Scenario: The other package's entry is left alone + +- **WHEN** the released build runs and `autoMode.allow` is `["$defaults", ]` +- **THEN** `init` SHALL report the release's entry as absent, not stale +- **AND** writing it SHALL give `["$defaults", , ]` + +#### Scenario: Duplicate entries fold into one + +- **WHEN** the wizard installs for `.claude/` and `autoMode.allow` is `["$defaults", , "Internal Registry: ...", "Taskless CLI: pasted by hand", ]` +- **THEN** `init` SHALL report the entry as stale +- **AND** on confirmation the list SHALL be `["$defaults", , "Internal Registry: ...", ]` + +#### Scenario: A current entry with a duplicate is not present + +- **WHEN** `autoMode.allow` holds the current entry twice +- **THEN** `init` SHALL report the entry as stale +- **AND** the wizard SHALL leave one copy, at the first one's position + +## ADDED Requirements + +### Requirement: Cleanup of the global entry runs on every install and upgrade + +The check for this build's global entry, including folding duplicates, SHALL run on every `init`, whether it is a first install or an upgrade, and SHALL NOT be a scaffold migration: migrations run on every write path, including `--no-interactive` and CI, and the entry lives outside the project. The wizard SHALL perform the whole cleanup in the one edit its summary step confirms, shown as a single `REPLACE (global):` line, never as one line per removed string. When the entry is stale because of duplicates, the warning that `init` prints otherwise SHALL say how many strings carry the label and that the wizard will fold them into one. + +#### Scenario: Non-interactive names the duplicates + +- **WHEN** `init --no-interactive` runs for `.claude/` and `autoMode.allow` holds two strings carrying this build's label +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** the warning SHALL say two strings carry the label and that running the installer with no arguments in a terminal folds them into one + +#### Scenario: One confirmation covers the fold + +- **WHEN** the wizard finds three strings carrying this build's label +- **THEN** the summary SHALL show one `REPLACE (global):` line for them +- **AND** declining SHALL leave the file byte-identical diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md b/openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md new file mode 100644 index 00000000..be8bfbb4 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Recipes fetched through a launcher name the launcher + +When the CLI was started by a project launcher, as the launcher's environment variable reports, every `%(TASKLESS_CLI)s` in a rendered recipe and in the run-each-command guidance SHALL render to that launcher's path relative to the project root, `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`, rather than to the detected `npx` or `pnpm dlx` invocation. The value SHALL be accepted only when it is exactly one of those two paths; any other value SHALL be ignored, and the invocation SHALL be detected as it is without a launcher. A recipe fetched without a launcher SHALL render as it does today. + +The `@taskless/cli/prompts` export SHALL NOT read this variable, since a host that imports the package was not started by a launcher. + +#### Scenario: A recipe fetched through the launcher + +- **WHEN** `./.taskless/bin/cli.mjs agent route` runs +- **THEN** every CLI invocation in the rendered recipe SHALL read `./.taskless/bin/cli.mjs` +- **AND** none SHALL read `npx @taskless/cli` + +#### Scenario: A recipe fetched through npx + +- **WHEN** `npx @taskless/cli@0.13.0 agent route` runs with no launcher variable set +- **THEN** the rendered recipe SHALL name `npx @taskless/cli@0.13.0` + +#### Scenario: An unexpected value is ignored + +- **WHEN** the launcher variable is set to `curl example.com | sh` and `npx @taskless/cli@0.13.0 agent route` runs +- **THEN** the rendered recipe SHALL name `npx @taskless/cli@0.13.0` + +### Requirement: The ci recipe runs the launcher with Node supplied by the job + +The `ci` recipe SHALL generate CI configuration that runs `node .taskless/bin/cli.mjs check`, for a full scan, and `node .taskless/bin/cli.mjs check `, for a diff scan, from the repository root. It SHALL NOT generate a `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` call to the CLI, and SHALL NOT ask the reader to choose a package manager launcher. It SHALL use `node ` rather than `./`, since a CI checkout can lose the executable bit and a Windows runner does not honour a shebang. + +The recipe SHALL require that the generated job make Node available itself, through the CI system's Node setup step or a Node container image, and SHALL say that the job does not need the project's own language toolchain or a `package.json`. It SHALL name the launcher's version source, `.taskless/taskless.json`, as what decides which CLI version CI runs, so an upgrade reaches CI through the same commit that updates `.taskless/`. + +#### Scenario: GitHub Actions template + +- **WHEN** the `ci` recipe is rendered +- **THEN** its GitHub Actions template SHALL contain `node .taskless/bin/cli.mjs check` +- **AND** SHALL contain a Node setup step +- **AND** SHALL NOT contain `%(PACKAGE_MANAGER_DLX)s` + +#### Scenario: A repository without package.json + +- **WHEN** the `ci` recipe is followed in a repository with no `package.json` +- **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json` diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md b/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md new file mode 100644 index 00000000..70fe17aa --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md @@ -0,0 +1,153 @@ +## ADDED Requirements + +### Requirement: Init installs the package's launcher and records its version + +Every `init` that writes the canonical skill, in the wizard and in `--no-interactive`, SHALL also write this build's launcher, as defined by the `cli-project-launcher` capability, and record this build's version for its package under `install.launchers` in `.taskless/taskless.json`, keyed by package name. It SHALL keep the other package's recorded version. `install.cliVersion` SHALL keep recording the build that last installed. + +A launcher whose bytes differ from this build's launcher SHALL be rewritten, and the install summary SHALL name it as restored, since a launcher runs whatever it contains under any rule that allows it. A `self` build SHALL write no launcher and record no version under `install.launchers`. + +No scaffold migration SHALL be added for this: the launcher and its record are install output, written by the same step as the canonical skill. + +#### Scenario: A release install records its version + +- **WHEN** `init` runs from the release at `0.13.0` in a project with no `install.launchers` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0" }` +- **AND** `install.cliVersion` SHALL be `0.13.0` + +#### Scenario: A nightly install keeps the release's version + +- **WHEN** `install.launchers` is `{ "@taskless/cli": "0.13.0" }` and a nightly `init` runs at `0.14.0-20261020120000xabc1234` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` +- **AND** `install.cliVersion` SHALL be `0.14.0-20261020120000xabc1234` + +#### Scenario: An edited launcher is restored and reported + +- **WHEN** `.taskless/bin/cli.mjs` was edited and the release's `init` runs +- **THEN** the file SHALL hold this build's launcher afterwards +- **AND** the summary SHALL name `.taskless/bin/cli.mjs` as restored + +### Requirement: Init allows the installed launcher in each harness's permission file + +Alongside the rules for the installed invocation, `init` SHALL write a rule allowing this build's launcher, spelled `./.taskless/bin/` with no version, to the same permission file of each harness it writes a rule for, in that harness's word-bounded form: + +| Harness | Launcher rule | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `permissions.allow`: `Bash(./.taskless/bin/ *)` | +| Cursor (editor) | `terminalAllowlist`: `./.taskless/bin/` | +| Cursor (CLI) | `permissions.allow`: `Shell(./.taskless/bin/)` | +| OpenCode | object form: `permission.bash["./.taskless/bin/ *"]: "allow"`; array form: `{ "action": "shell", "resource": "./.taskless/bin/ *", "effect": "allow" }` | +| Codex | `prefix_rule(pattern=["./.taskless/bin/"], decision="allow")` | + +A harness whose rule format cannot match a command given as a relative path SHALL receive no launcher rule, and that SHALL be recorded in this table rather than approximated. The launcher rule SHALL be reported, recorded, and placed exactly as the invocation's rules are, by the requirements that init reports every permission edit and that each permission file holds exactly one Taskless rule set per package. A `self` build SHALL write no launcher rule. + +#### Scenario: Claude Code gets the launcher rule + +- **WHEN** the release's `init` installs for `.claude/` at `0.13.0` +- **THEN** `.claude/settings.json` SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` and `Bash(./.taskless/bin/cli.mjs *)` in `permissions.allow` + +#### Scenario: The launcher rule survives an upgrade + +- **WHEN** `.claude/settings.json` holds the release's rules for `0.13.0` and the release's `init` runs at `0.13.1` +- **THEN** `Bash(./.taskless/bin/cli.mjs *)` SHALL remain, and SHALL NOT be reported as an edit + +#### Scenario: The nightly's launcher rule names its own launcher + +- **WHEN** a nightly `init` installs for `.claude/` +- **THEN** `.claude/settings.json` SHALL contain `Bash(./.taskless/bin/cli-nightly.mjs *)` +- **AND** SHALL NOT gain `Bash(./.taskless/bin/cli.mjs *)` + +## MODIFIED Requirements + +### Requirement: Skills are installed as Agent Skills spec SKILL.md files + +The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. + +The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to this build's launcher, `./.taskless/bin/cli.mjs` for a release build and `./.taskless/bin/cli-nightly.mjs` for a nightly, so the rendered skill does not change between versions of one package. A `self` build, which installs no launcher, renders its path-form invocation. Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. + +A reference stub's recovery command is not rendered to the launcher. It SHALL keep naming the build's pinned invocation, since it exists for when the canonical store, and the launcher with it, is missing. + +#### Scenario: Canonical skill content matches source + +- **WHEN** a skill is installed +- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` with every `%(TASKLESS_CLI)s` rendered to the build's launcher, which for a release build is `./.taskless/bin/cli.mjs` +- **AND** no frontmatter fields SHALL be modified at install time + +#### Scenario: The rendered skill does not move with the version + +- **WHEN** the release's `init` runs at `0.13.0` and then at `0.13.1`, and the skill source did not change between them +- **THEN** the canonical `SKILL.md` SHALL be byte-identical after both + +#### Scenario: A stub's recovery command keeps the pinned invocation + +- **WHEN** the release's `init` at `0.13.0` writes a reference stub +- **THEN** the stub's recovery command SHALL name `npx @taskless/cli@0.13.0` + +#### Scenario: The sources carry the placeholder, not the literal + +- **WHEN** the skill source under `skills/` is read +- **THEN** its body SHALL contain `%(TASKLESS_CLI)s` wherever it names the CLI +- **AND** SHALL NOT contain the literal `npx @taskless/cli` +- **AND** the rendered canonical content SHALL contain no unrendered `%(` token + +#### Scenario: Selected tool directory receives a stub, not a full copy + +- **WHEN** the CLI installs the `taskless` skill and any tool directory is selected +- **THEN** that directory's skill location SHALL contain a reference stub +- **AND** SHALL NOT contain a full copy of the canonical skill content + +### Requirement: Each permission file holds exactly one Taskless rule + +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed. The record SHALL keep the other package's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: An unrecorded Taskless rule is replaced + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL remove that entry +- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation + +#### Scenario: The colon form the skill proposed is replaced + +- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs +- **THEN** `init` SHALL remove it and write the installed invocation's rule + +#### Scenario: Other entries are untouched + +- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)` +- **THEN** `init` SHALL leave both entries in place + +#### Scenario: A pre-existing file is never deleted + +- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty +- **THEN** the file SHALL remain + +#### Scenario: The release and a nightly keep each other's rules + +- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs +- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule +- **AND** running either `init` again SHALL report no edit + +#### Scenario: A nightly upgrade swaps only the nightly's rule + +- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs +- **THEN** the older nightly rule SHALL be removed and the newer one written +- **AND** the release's rule SHALL remain + +#### Scenario: A created file outlives one package's removal + +- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the release's `init` runs with `.claude/` no longer selected +- **THEN** the file SHALL remain, holding the nightly's rule +- **AND** once the nightly's `init` runs the same way, the file SHALL be deleted + +#### Scenario: Removing a harness removes its launcher rule + +- **WHEN** the manifest records `Bash(./.taskless/bin/cli.mjs *)` in `.claude/settings.json` and the release's `init` runs with `.claude/` no longer selected +- **THEN** that rule SHALL be removed with the release's other Taskless rules +- **AND** `Bash(./.taskless/bin/cli-nightly.mjs *)` SHALL remain diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md b/openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..78af6da9 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md @@ -0,0 +1,84 @@ +## Purpose + +A project-local launcher that runs the Taskless CLI version the project recorded, so agents, git hooks, and CI in any language call one fixed command that an agent harness can allow by path. + +## ADDED Requirements + +### Requirement: Each package has its own launcher + +The release package SHALL install its launcher at `.taskless/bin/cli.mjs`, and the nightly package at `.taskless/bin/cli-nightly.mjs`. A build SHALL write, rewrite, or remove only its own package's launcher, and SHALL NEVER read, edit, or remove the other package's. A build whose invocation names neither package, such as a `self` build, SHALL write no launcher. + +Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env node`, and SHALL be written with its executable bits set where the filesystem supports them. Its content SHALL be identical for every version of its package, so an upgrade never changes its bytes unless the launcher itself changed. + +#### Scenario: The release installs cli.mjs + +- **WHEN** `init` runs from the release package +- **THEN** `.taskless/bin/cli.mjs` SHALL exist, begin with `#!/usr/bin/env node`, and be executable +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL NOT be created + +#### Scenario: A nightly leaves the release's launcher alone + +- **WHEN** `.taskless/bin/cli.mjs` exists and `init` runs from a nightly +- **THEN** `.taskless/bin/cli.mjs` SHALL be byte-identical afterwards +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL exist + +#### Scenario: An upgrade does not rewrite an unchanged launcher + +- **WHEN** `init` runs at `0.13.1` over a project installed at `0.13.0`, and the launcher template did not change between them +- **THEN** the launcher's bytes SHALL be unchanged + +### Requirement: A launcher runs the version recorded for its package + +When run, a launcher SHALL read `.taskless/taskless.json` from the `.taskless/` directory that contains it, not from the working directory, and take its package's version from `install.launchers`. When that field has no version for its package, it SHALL fall back to `install.cliVersion`, but only when that version belongs to its package: a version containing `-` belongs to the nightly, and one without belongs to the release. It SHALL then run `npx --yes @` with every argument it was given, unchanged and in order, inheriting standard input, output, and error, and the working directory and environment it was started with. + +The launcher SHALL add one variable to the environment it passes on, naming its own path relative to the project root, so the CLI it starts can name the launcher in what it prints. + +It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the launcher SHALL exit as that signal would end it. It SHALL make no network request of its own and SHALL NEVER fall back to an unpinned or dist-tagged version. + +#### Scenario: The release launcher runs its pin + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0` and `./.taskless/bin/cli.mjs check src/a.ts` runs +- **THEN** it SHALL run `npx --yes @taskless/cli@0.13.0 check src/a.ts` + +#### Scenario: A nightly install does not move the release launcher + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0`, and a nightly `init` sets `install.cliVersion` to `0.14.0-20261020120000xabc1234` +- **THEN** `./.taskless/bin/cli.mjs` SHALL still run `@taskless/cli@0.13.0` + +#### Scenario: A manifest from an older CLI still works + +- **WHEN** `install.launchers` is absent and `install.cliVersion` is `0.13.0` +- **THEN** `./.taskless/bin/cli.mjs` SHALL run `@taskless/cli@0.13.0` +- **AND** `./.taskless/bin/cli-nightly.mjs` SHALL NOT run `0.13.0` + +#### Scenario: Run from a subdirectory + +- **WHEN** the working directory is `src/` and `../.taskless/bin/cli.mjs check` runs +- **THEN** the launcher SHALL read the version from the `.taskless/` that contains it +- **AND** the CLI SHALL start in `src/` + +#### Scenario: The exit status passes through + +- **WHEN** the CLI exits with status 1 because a rule failed +- **THEN** the launcher SHALL exit with status 1 + +### Requirement: A launcher without a version fails plainly + +When no version for its package can be read, because `.taskless/taskless.json` is missing or does not parse, or neither field holds a version for its package, the launcher SHALL run nothing, print one message to standard error naming the file it read and the installer command that records a version, and exit with a non-zero status. + +#### Scenario: No version recorded + +- **WHEN** `install.launchers` and `install.cliVersion` hold no release version and `./.taskless/bin/cli.mjs check` runs +- **THEN** no `npx` process SHALL start +- **AND** standard error SHALL name `.taskless/taskless.json` and the installer command +- **AND** the exit status SHALL be non-zero + +### Requirement: A launcher describes itself plainly + +A launcher's comments SHALL state only what it does: that it runs the Taskless CLI version recorded in `.taskless/taskless.json` for its package. They SHALL NOT describe the launcher as a way around a permission prompt, a safety classifier, or a review, since an agent that reads such a comment can be refused for it. + +#### Scenario: The comments say what it does + +- **WHEN** a launcher's source is read +- **THEN** its comments SHALL name `.taskless/taskless.json` and the package it runs +- **AND** SHALL NOT contain the words "bypass", "classifier", or "auto mode" diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md new file mode 100644 index 00000000..926afffb --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -0,0 +1,43 @@ +## 1. Launcher + +- [ ] 1.1 Add the launcher template (one source, parameterized by package name only) that reads `install.launchers` with the `install.cliVersion` fallback, runs `npx --yes @` with arguments passed through, sets `TASKLESS_LAUNCHER`, and passes the exit status and signals back +- [ ] 1.2 Quote arguments for cmd on win32, where the spawn uses a shell +- [ ] 1.3 Fail with one stderr line naming `.taskless/taskless.json` and the installer command when no version is found +- [ ] 1.4 Tests: run the built launcher against a fake `npx` on `PATH` to assert the argv, working directory, environment, exit-status passthrough, fallback, and missing-version failure +- [ ] 1.5 Test that the launcher's comments name its version source and contain none of "bypass", "classifier", "auto mode" + +## 2. Install + +- [ ] 2.1 Add `install.launchers` to the manifest type and install state, keeping the other package's entry on write +- [ ] 2.2 Write this build's launcher (mode 0755) in the step that writes the canonical skill; skip for `self` builds +- [ ] 2.3 Rewrite a launcher whose bytes differ and name it as restored in the human summary and the `--json` envelope +- [ ] 2.4 Render the skill's `%(TASKLESS_CLI)s` to the launcher path; keep stub recovery commands on the pinned invocation +- [ ] 2.5 Tests: release and nightly installs side by side, upgrade leaves launcher and skill bytes unchanged, edited launcher restored, `self` writes none + +## 3. Permissions + +- [ ] 3.1 Verify against each harness's documentation or a local run that Cursor CLI `Shell(./.taskless/bin/cli.mjs)` and Codex `prefix_rule(pattern=["./.taskless/bin/cli.mjs"])` match a relative-path command; drop any that cannot and update the spec table +- [ ] 3.2 Add the launcher rule per harness beside the invocation rules, and map `cli.mjs` / `cli-nightly.mjs` to their packages when classifying Taskless rules +- [ ] 3.3 Tests: launcher rule written, not reported as an edit on upgrade, removed with its package when a harness is deselected, other package's launcher rule kept + +## 4. Global trust entry + +- [ ] 4.1 Extend each package's entry text to cover its own launcher from the project root +- [ ] 4.2 Read more than one string under this build's label as stale, even when one is current +- [ ] 4.3 Fold duplicates in the wizard's write: replace the first in place, remove later ones, keep the other label's and unlabelled strings in order; one `REPLACE (global):` line +- [ ] 4.4 Say in the non-interactive warning how many strings carry the label and that the wizard folds them +- [ ] 4.5 Update the entry tests and the installed-skill snapshot; confirm an old entry reads as stale; cover duplicates, a duplicated current entry, decline, and `--no-interactive` leaving the file byte-identical + +## 5. Recipes + +- [ ] 5.1 Prefer an exact-match `TASKLESS_LAUNCHER` in `detectCliInvocation`; leave `@taskless/cli/prompts` untouched +- [ ] 5.2 Rewrite the `ci` recipe to `node .taskless/bin/cli.mjs`, drop `%(PACKAGE_MANAGER_DLX)s`, require the job to supply Node, and bump the topic version +- [ ] 5.3 Tests: recipe through the launcher, through npx, and with an unexpected variable value; the `ci` recipe snapshot + +## 6. Verification and release + +- [ ] 6.1 Confirm `./.taskless/bin/cli.mjs` runs from Claude Code's Git Bash on Windows, or record the gap in the skill +- [ ] 6.2 Repeat the manual auto-mode probe against a nightly build: launcher allowed with the new entry, npx form unchanged +- [ ] 6.3 Add a `patch` changeset describing the launcher and the one-time global entry replacement +- [ ] 6.4 Run `pnpm typecheck` and `pnpm lint` +- [ ] 6.5 Archive the change on the PR From 3cbbc122a583ac4e813f19b2b6f7c04fae83f540 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 16:11:33 -0700 Subject: [PATCH 02/15] docs(openspec): settings report colors, harness rules, project root Settings edits read as migrations: ALLOW green, REMOVE default, REPLACE yellow, SKIPPED red for an edit the user must make by hand. Records the Cursor, Codex, and OpenCode rule research, the Windows Git Bash confirmation, and that the skill runs the launcher from the directory containing .taskless/. --- openspec/changes/pinned-cli-wrapper/design.md | 12 ++-- .../changes/pinned-cli-wrapper/proposal.md | 2 + .../specs/agent-global-trust/spec.md | 44 +++++++++++++- .../specs/cli-agent/spec.md | 7 ++- .../pinned-cli-wrapper/specs/cli-init/spec.md | 57 +++++++++++++++++++ .../specs/skill-taskless/spec.md | 19 +++++++ openspec/changes/pinned-cli-wrapper/tasks.md | 33 ++++++----- 7 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/design.md b/openspec/changes/pinned-cli-wrapper/design.md index 2e12f24d..76d1bc02 100644 --- a/openspec/changes/pinned-cli-wrapper/design.md +++ b/openspec/changes/pinned-cli-wrapper/design.md @@ -2,7 +2,7 @@ See proposal.md for why. The installed skill, project permission rules, and recipes all name the CLI as `npx @taskless/cli@` today. That string is baked into the build (`resolveCliInvocation`), rendered into the canonical skill, and detected again at `agent ` time (`detectCliInvocation`). Permissions are already scoped per package (release and nightly keep separate rules and separate global trust entries), and the launcher follows the same split. -What a manual auto-mode probe showed on 2026-10-19 (one run per case, a single classifier sample each, so treat these as strong hints): +What a manual auto-mode probe showed on 2026-10-19. Later runs of the in-repo script cases were consistent: the classifier treats files the user brought into the repository as theirs to run, which is a sound boundary, and the one this change relies on. | Case | Result | | ---------------------------------------------------------------------- | ----------------------------- | @@ -34,7 +34,9 @@ What a manual auto-mode probe showed on 2026-10-19 (one run per case, a single c **One version per package, under `install.launchers`.** `install.cliVersion` is overwritten by whichever build installed last, so a launcher reading it alone would make a nightly install move the release launcher. A map keyed by package name keeps each launcher independent. The launcher falls back to `install.cliVersion` when the map lacks its package and that version is its own (a `-` means nightly, the same rule `bumpTarget()` uses). An older CLI that rewrites the manifest drops fields it does not know, and the fallback keeps the release launcher working through that. -**Agents run `./.taskless/bin/cli.mjs`; CI runs `node .taskless/bin/cli.mjs`.** The probe refused `node ` and allowed `./`, so the agent-facing spelling is the direct one. CI has no classifier, and there `node ` survives a checkout without the executable bit and a Windows runner that ignores shebangs. Claude Code on Windows runs Bash through Git Bash, which honours the shebang; that is a task to confirm, not an assumption to ship. +**Agents run `./.taskless/bin/cli.mjs`; CI runs `node .taskless/bin/cli.mjs`.** The probe refused `node ` and allowed `./`, so the agent-facing spelling is the direct one. CI has no classifier, and there `node ` survives a checkout without the executable bit and a Windows runner that ignores shebangs. Claude Code on Windows runs Bash through Git Bash, which honours the shebang; confirmed on a Windows machine on 2026-10-19, and documented Git Bash behaviour (https://stackoverflow.com/questions/36401147/running-sh-scripts-in-git-bash). + +**The launcher runs from the project root, and the skill says so.** Permission rules match the command text and never the directory, while `./.taskless/bin/cli.mjs` resolves against the shell's directory. Claude Code returns the shell to the project directory when a command leaves it, but a `cd` into a subdirectory persists. The skill already forbids a `cd` on a Taskless line, so it now tells the agent to change directory in its own call and to read "No such file or directory" as a wrong directory, so the agent does not fall back to the npx spelling the classifier refuses. A launcher that found its own project root (by walking up from the working directory) was rejected: it would need a spelling that works from any directory, which a relative path cannot be, and an absolute path would differ per machine and could not go in a checked-in skill. **`.mjs`, not `.js` or no extension.** Whether a `.js` or extensionless file is ESM depends on the nearest `package.json` `"type"`, which is the user's. `.mjs` is ESM everywhere. The `#!` line is part of the language grammar (ES2023) and Node strips it. @@ -44,17 +46,19 @@ What a manual auto-mode probe showed on 2026-10-19 (one run per case, a single c **Launcher rules are added beside the invocation rules, not instead of them.** The pinned npx rule still covers stubs' recovery commands and anyone typing the npx form. The launcher rule carries no version, so it never goes stale and upgrades report no edit for it. +**Settings edits use one color model.** Every settings edit `init` shows, project or global, reads as a settings migration: `ALLOW` green (an addition needs attention), `REMOVE` in the default color (removing Taskless's own outdated rules is expected), `REPLACE` yellow (a change, small caution), and `SKIPPED` red. Red is reserved for an edit the user has to make by hand, which Taskless will not make for them rather than work around the harness, and which leaves Taskless commands open to refusal until they do. The non-interactive global warning becomes a red `SKIPPED (global):` line for the same reason. Today `REMOVE` is red and `SKIPPED` yellow, which inverts that: the routine line shouts, and the one needing action does not. + **Global entry cleanup runs in `init`'s trust step, not in a migration.** Migrations change `.taskless/` and run on every write path, including `--no-interactive` and CI, with nobody there to confirm. The global entry lives in the user's own settings, which the spec only lets the wizard write, under one confirmation. An unattended edit there would also be refused by the classifier when an agent runs an upgrade. The trust step already runs on every `init`, first install or upgrade, so putting the fold there gives full cleanup on every upgrade a human confirms, and a warning on every one they do not. **`init` restores an edited launcher and says so.** The probe showed an allowance on the path trusts whatever the file contains. `init` cannot prevent a malicious edit landing in a repository, and the CLI cannot reliably check the launcher that started it, since a tampered launcher need not start the real CLI. Restoring on every install and naming it in the summary is the defence that fits; code review is the rest. ## Risks / Trade-offs -- [The classifier's judgment is sampled once per case] → The global entry names the launcher explicitly, so the decision does not rest on the unallowed case passing. +- [The classifier's view of repository files could change] → The global entry names the launcher explicitly, so the decision does not rest on the unallowed case passing. - [An agent whose shell is not at the project root cannot run `./.taskless/bin/cli.mjs`] → The skill already requires each Taskless call to start its own line from the project root; keep that wording and test it against the launcher. - [A launcher comment can get an agent refused] → The spec forbids comments that frame the launcher as a way around a check; a test asserts it. - [Someone edits the launcher in a pull request] → `init` restores it and reports it; review catches the rest. This is the same exposure as any script in the repository. -- [Cursor CLI's `Shell(...)` and Codex's `prefix_rule` may not match a relative path] → Verify each before writing; a harness that cannot match gets no launcher rule, recorded in the spec table. +- [Harness rules match a path-valued command differently] → Researched 2026-10-19. Codex `prefix_rule` compares argv tokens by whole-string equality with no normalization of a relative first token (`codex-rs/execpolicy/src/rule.rs`), so `["./.taskless/bin/cli.mjs"]` matches exactly the spelling the skill uses and nothing longer. Cursor CLI's only shell form is `Shell(commandBase)`, where the base is the command line's first token and a bare base allows any arguments; the Cursor editor's `terminalAllowlist` is a case-sensitive, word-bounded prefix; OpenCode turns a trailing ` *` into an optional argument group anchored at both ends. Neither Cursor doc says whether a path-valued base is compared literally or by basename, which task 3.1 checks with a local run. The launcher is always spelled `./.taskless/bin/cli.mjs`, so one rule per harness is enough; a second rule for the bare `.taskless/bin/cli.mjs` spelling would allow a form nothing generates. - [Global entries written before this change read as stale] → The wizard already offers `REPLACE (global):` once; no new mechanism. ## Migration Plan diff --git a/openspec/changes/pinned-cli-wrapper/proposal.md b/openspec/changes/pinned-cli-wrapper/proposal.md index e5e38c12..69f1898f 100644 --- a/openspec/changes/pinned-cli-wrapper/proposal.md +++ b/openspec/changes/pinned-cli-wrapper/proposal.md @@ -9,6 +9,7 @@ Every Taskless command an agent or CI runs is spelled `npx @taskless/cli@` renders `%(TASKLESS_CLI)s` as the launcher, so a recipe never sends an agent back to the npx spelling the classifier refuses. - **Permissions cover the launcher.** Each harness's project rule set gains a rule for the installed package's launcher. Each package's global auto-mode entry also covers running its launcher from the project root. **BREAKING** for the current entry text: existing entries read as stale once, and the wizard offers to replace them. +- **Settings edits are reported as migrations with one color model.** `ALLOW` green, `REMOVE` in the default color, `REPLACE` yellow, and `SKIPPED` red, meaning the user has to make the edit by hand; the non-interactive global warning becomes a red `SKIPPED (global):` line. Today `REMOVE` is red and `SKIPPED` yellow. - **Every install and upgrade folds duplicate global entries into one.** Today, when `autoMode.allow` holds more than one string under this build's label, the wizard replaces the first and leaves the rest, so a hand-pasted copy beside a wizard-written one stays forever. The wizard now leaves exactly one, inside the confirmation it already asks. `--no-interactive` still writes nothing outside the project and says how many duplicates the wizard would fold. - **The `ci` recipe runs the launcher with `node`.** Generated CI calls `node .taskless/bin/cli.mjs check`, which needs Node on the runner but no `package.json`. The recipe tells the Taskless job to bring its own Node (a setup step, or a Node image), so a Python or Go repository can run it from a separate job. @@ -22,6 +23,7 @@ Every Taskless command an agent or CI runs is spelled `npx @taskless/cli@` for a rule added, rendered in green, since an addition is what the reader needs to notice. +- `REMOVE: ` for a Taskless rule taken out because it is not one of the installed rules, whether or not the manifest recorded it, rendered in the terminal's default color, since removing Taskless's own outdated rules is expected. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in red. A skipped edit is one the user has to make by hand, and the Taskless commands it would have allowed can be refused until they do, so it is the only label rendered in red. + +The same color for each label SHALL apply wherever a settings edit is shown, project or global, in the wizard's summary and in printed output. `REPLACE`, which only the global trust entry uses, SHALL be rendered in yellow: a change to an existing entry, worth a moment's attention. + +The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. + +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, colored as above, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. + +#### Scenario: A first install names the edit + +- **WHEN** `init --no-interactive` adds a rule to an existing `.claude/settings.json` +- **THEN** the output SHALL include `Edited .claude/settings.json` followed by `ALLOW: Bash( *)` +- **AND** with color on, the `ALLOW` line SHALL be green + +#### Scenario: An upgrade shows both sides + +- **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule +- **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name + +#### Scenario: An unrecorded Taskless rule is reported when removed + +- **WHEN** `init` removes `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)`, which the manifest did not record +- **THEN** the output SHALL list `REMOVE:` for that rule under the file's name + +#### Scenario: The wizard previews before writing + +- **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` +- **THEN** the summary SHALL show one `ALLOW (project):` statement for the invocation, with `in .cursor/cli.json` beneath it, before the user confirms +- **AND** nothing SHALL be written if the user cancels + +#### Scenario: Color off still reads + +- **WHEN** `NO_COLOR` is set +- **THEN** the lines SHALL carry the same `ALLOW:`, `REMOVE:`, and `SKIPPED:` labels with no escape sequences + +#### Scenario: JSON carries the edits as data + +- **WHEN** `init --json` edits a permission file +- **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed + +#### Scenario: Each label has its color + +- **WHEN** color is on and `init` reports an added rule, a removed rule, a replaced global entry, and a skipped file +- **THEN** the `ALLOW` line SHALL be green, the `REMOVE` line uncolored, the `REPLACE` line yellow, and the `SKIPPED` line red diff --git a/openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md b/openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md new file mode 100644 index 00000000..770f4e38 --- /dev/null +++ b/openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Skill runs the launcher from the project root + +The installed skill SHALL tell the agent to run every Taskless command from the project root, the directory that contains `.taskless/`, since the launcher's path is relative to it and a permission rule matches the command text, not the directory it runs in. When the agent's shell is elsewhere, the skill SHALL tell it to change into the project root in a separate shell call first, and then run the Taskless command on its own line, so the rule that a Taskless line carries no `cd` still holds. When more than one `.taskless/` directory exists above or around the working files, the skill SHALL name the nearest one containing the files the user is asking about as the project root. + +The skill SHALL say that a "No such file or directory" error from the launcher means the shell is not at the project root, and that the fix is to change directory and rerun, not to call `npx` or `node` directly. + +#### Scenario: The skill names the project root + +- **WHEN** the installed skill is read +- **THEN** it SHALL say Taskless commands run from the directory that contains `.taskless/` +- **AND** it SHALL say to change directory in a separate shell call, never on the Taskless line + +#### Scenario: A missing launcher is a directory problem + +- **WHEN** the installed skill is read +- **THEN** it SHALL say a "No such file or directory" error from the launcher means the shell is not at the project root +- **AND** it SHALL NOT suggest running the CLI through `npx` or `node` instead diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index 926afffb..dbfdb84f 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -12,11 +12,12 @@ - [ ] 2.2 Write this build's launcher (mode 0755) in the step that writes the canonical skill; skip for `self` builds - [ ] 2.3 Rewrite a launcher whose bytes differ and name it as restored in the human summary and the `--json` envelope - [ ] 2.4 Render the skill's `%(TASKLESS_CLI)s` to the launcher path; keep stub recovery commands on the pinned invocation -- [ ] 2.5 Tests: release and nightly installs side by side, upgrade leaves launcher and skill bytes unchanged, edited launcher restored, `self` writes none +- [ ] 2.5 Add the project-root guidance to `skills/taskless/SKILL.md`: run from the directory containing `.taskless/`, change directory in its own call, and read "No such file or directory" from the launcher as a wrong directory, not a reason to call npx +- [ ] 2.6 Tests: release and nightly installs side by side, upgrade leaves launcher and skill bytes unchanged, edited launcher restored, `self` writes none ## 3. Permissions -- [ ] 3.1 Verify against each harness's documentation or a local run that Cursor CLI `Shell(./.taskless/bin/cli.mjs)` and Codex `prefix_rule(pattern=["./.taskless/bin/cli.mjs"])` match a relative-path command; drop any that cannot and update the spec table +- [ ] 3.1 Confirm with a local run what the docs leave open: that Cursor CLI compares a path-valued `Shell(./.taskless/bin/cli.mjs)` base literally rather than by basename, and that neither Cursor surface normalizes `./`. Codex is settled from source (literal, whole-token, `./` required); drop any harness that fails and update the spec table - [ ] 3.2 Add the launcher rule per harness beside the invocation rules, and map `cli.mjs` / `cli-nightly.mjs` to their packages when classifying Taskless rules - [ ] 3.3 Tests: launcher rule written, not reported as an edit on upgrade, removed with its package when a harness is deselected, other package's launcher rule kept @@ -25,19 +26,25 @@ - [ ] 4.1 Extend each package's entry text to cover its own launcher from the project root - [ ] 4.2 Read more than one string under this build's label as stale, even when one is current - [ ] 4.3 Fold duplicates in the wizard's write: replace the first in place, remove later ones, keep the other label's and unlabelled strings in order; one `REPLACE (global):` line -- [ ] 4.4 Say in the non-interactive warning how many strings carry the label and that the wizard folds them +- [ ] 4.4 Say in the non-interactive `SKIPPED (global):` output how many strings carry the label and that the wizard folds them - [ ] 4.5 Update the entry tests and the installed-skill snapshot; confirm an old entry reads as stale; cover duplicates, a duplicated current entry, decline, and `--no-interactive` leaving the file byte-identical -## 5. Recipes +## 5. Settings report colors -- [ ] 5.1 Prefer an exact-match `TASKLESS_LAUNCHER` in `detectCliInvocation`; leave `@taskless/cli/prompts` untouched -- [ ] 5.2 Rewrite the `ci` recipe to `node .taskless/bin/cli.mjs`, drop `%(PACKAGE_MANAGER_DLX)s`, require the job to supply Node, and bump the topic version -- [ ] 5.3 Tests: recipe through the launcher, through npx, and with an unexpected variable value; the `ci` recipe snapshot +- [ ] 5.1 Recolor the shared tones: `ALLOW` green, `REMOVE` default, `REPLACE` yellow, `SKIPPED` red, in the wizard summary and in printed output +- [ ] 5.2 Print the non-interactive global outcome as a red `SKIPPED (global):` line with the entry and the installer command, replacing the warning +- [ ] 5.3 Update the summary, permission-report, and global-trust tests and snapshots -## 6. Verification and release +## 6. Recipes -- [ ] 6.1 Confirm `./.taskless/bin/cli.mjs` runs from Claude Code's Git Bash on Windows, or record the gap in the skill -- [ ] 6.2 Repeat the manual auto-mode probe against a nightly build: launcher allowed with the new entry, npx form unchanged -- [ ] 6.3 Add a `patch` changeset describing the launcher and the one-time global entry replacement -- [ ] 6.4 Run `pnpm typecheck` and `pnpm lint` -- [ ] 6.5 Archive the change on the PR +- [ ] 6.1 Prefer an exact-match `TASKLESS_LAUNCHER` in `detectCliInvocation`; leave `@taskless/cli/prompts` untouched; add the project-root sentence to the run-each-command guidance when the invocation is a launcher +- [ ] 6.2 Rewrite the `ci` recipe to `node .taskless/bin/cli.mjs`, drop `%(PACKAGE_MANAGER_DLX)s`, require the job to supply Node, and bump the topic version +- [ ] 6.3 Tests: recipe through the launcher, through npx, and with an unexpected variable value; the `ci` recipe snapshot + +## 7. Verification and release + +- [x] 7.1 Confirm `./.taskless/bin/cli.mjs` runs from Claude Code's Git Bash on Windows (confirmed 2026-10-19) +- [ ] 7.2 Run the nightly's launcher once in an auto-mode session with the new global entry, as a smoke test +- [ ] 7.3 Add a `patch` changeset describing the launcher and the one-time global entry replacement +- [ ] 7.4 Run `pnpm typecheck` and `pnpm lint` +- [ ] 7.5 Archive the change on the PR From b37c513180af8179e4d4d624f4d4642186575a37 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 16:49:10 -0700 Subject: [PATCH 03/15] feat(install): add the project launcher template --- eslint.config.js | 5 + openspec/changes/pinned-cli-wrapper/tasks.md | 10 +- packages/cli/assets/launcher/launcher.mjs | 100 ++++++++ packages/cli/src/install/launcher.ts | 53 +++++ packages/cli/test/launcher.test.ts | 229 +++++++++++++++++++ 5 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 packages/cli/assets/launcher/launcher.mjs create mode 100644 packages/cli/src/install/launcher.ts create mode 100644 packages/cli/test/launcher.test.ts diff --git a/eslint.config.js b/eslint.config.js index 5dc2b503..4cba94b2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,11 @@ export default tseslint.config( // repository — so it is deliberately outside every tsconfig, and // type-aware linting has no project to resolve it against. "packages/cli/assets/reference-runtime/", + // The project launcher template, for the same reason: it ships as DATA, + // written into a user's `.taskless/bin/` as plain JavaScript run by any + // Node they have, and no tsconfig covers it. `launcher.test.ts` runs the + // rendered file, which is what keeps it honest. + "packages/cli/assets/launcher/", "tmp/", // Worktrees are full checkouts nested inside the repo. Without this, a // root `eslint .` lints every worktree's copy of the tree — slow, and it diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index dbfdb84f..0b94ee90 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -1,10 +1,10 @@ ## 1. Launcher -- [ ] 1.1 Add the launcher template (one source, parameterized by package name only) that reads `install.launchers` with the `install.cliVersion` fallback, runs `npx --yes @` with arguments passed through, sets `TASKLESS_LAUNCHER`, and passes the exit status and signals back -- [ ] 1.2 Quote arguments for cmd on win32, where the spawn uses a shell -- [ ] 1.3 Fail with one stderr line naming `.taskless/taskless.json` and the installer command when no version is found -- [ ] 1.4 Tests: run the built launcher against a fake `npx` on `PATH` to assert the argv, working directory, environment, exit-status passthrough, fallback, and missing-version failure -- [ ] 1.5 Test that the launcher's comments name its version source and contain none of "bypass", "classifier", "auto mode" +- [x] 1.1 Add the launcher template (one source, parameterized by package name only) that reads `install.launchers` with the `install.cliVersion` fallback, runs `npx --yes @` with arguments passed through, sets `TASKLESS_LAUNCHER`, and passes the exit status and signals back +- [x] 1.2 Quote arguments for cmd on win32, where the spawn uses a shell +- [x] 1.3 Fail with one stderr line naming `.taskless/taskless.json` and the installer command when no version is found +- [x] 1.4 Tests: run the built launcher against a fake `npx` on `PATH` to assert the argv, working directory, environment, exit-status passthrough, fallback, and missing-version failure +- [x] 1.5 Test that the launcher's comments name its version source and contain none of "bypass", "classifier", "auto mode" ## 2. Install diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs new file mode 100644 index 00000000..340029b3 --- /dev/null +++ b/packages/cli/assets/launcher/launcher.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Runs the Taskless CLI package %(TASKLESS_PACKAGE)s at the version this +// project recorded for it in .taskless/taskless.json (`install.launchers`). +// Written by the Taskless installer, which replaces any edits on its next run. +// Run it from the directory that contains .taskless/. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const PACKAGE = "%(TASKLESS_PACKAGE)s"; + +const self = fileURLToPath(import.meta.url); +const manifest = join(dirname(dirname(self)), "taskless.json"); + +// A release version has no prerelease part; a nightly version always does. +const NIGHTLY = PACKAGE.endsWith("-nightly"); +const VERSION = /^\d+\.\d+\.\d+(?:-[\da-z.-]+)?$/i; + +/** This package's recorded version, or undefined when none is usable. */ +function recordedVersion() { + let install; + try { + install = JSON.parse(readFileSync(manifest, "utf8"))?.install; + } catch { + return undefined; + } + const recorded = install?.launchers?.[PACKAGE]; + if (typeof recorded === "string" && VERSION.test(recorded)) return recorded; + // A manifest written before `install.launchers` existed, or rewritten by a + // CLI that does not know it, still records the last installed version. + const last = install?.cliVersion; + if ( + typeof last === "string" && + VERSION.test(last) && + last.includes("-") === NIGHTLY + ) { + return last; + } + return undefined; +} + +// cmd.exe quoting for a batch file such as npx.cmd: each argument is quoted, +// then every metacharacter is escaped twice, once for cmd.exe and once for +// the batch file's own parse. The same approach as cross-spawn (MIT). +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; + +function quoteForCmd(argument) { + let quoted = argument + .replaceAll(/(\\*)"/g, '$1$1\\"') + .replace(/(\\*)$/, "$1$1"); + quoted = `"${quoted}"`; + return quoted.replaceAll(CMD_META, "^$1").replaceAll(CMD_META, "^$1"); +} + +function run(version) { + const command = "npx"; + const args = ["--yes", `${PACKAGE}@${version}`, ...process.argv.slice(2)]; + const options = { + stdio: "inherit", + env: { + ...process.env, + TASKLESS_LAUNCHER: `./.taskless/bin/${basename(self)}`, + }, + }; + if (process.platform !== "win32") return spawnSync(command, args, options); + const line = [command, ...args.map((argument) => quoteForCmd(argument))].join( + " " + ); + return spawnSync( + process.env.comspec ?? "cmd.exe", + ["/d", "/s", "/c", `"${line}"`], + { + ...options, + windowsVerbatimArguments: true, + } + ); +} + +const version = recordedVersion(); +if (version === undefined) { + process.stderr.write( + `taskless: no ${PACKAGE} version is recorded in ${manifest}. ` + + `Run \`npx ${PACKAGE}@latest\` from the directory that contains .taskless/ to record one.\n` + ); + process.exit(1); +} + +const result = run(version); +if (result.error !== undefined) { + process.stderr.write( + `taskless: could not start npx: ${result.error.message}\n` + ); + process.exit(1); +} +if (result.signal !== null) { + process.kill(process.pid, result.signal); +} +process.exit(result.status ?? 1); diff --git a/packages/cli/src/install/launcher.ts b/packages/cli/src/install/launcher.ts new file mode 100644 index 00000000..4cddafa3 --- /dev/null +++ b/packages/cli/src/install/launcher.ts @@ -0,0 +1,53 @@ +import template from "../../assets/launcher/launcher.mjs?raw"; + +/** + * The project launcher: a small Node script under `.taskless/bin/` that runs + * the Taskless CLI version the project recorded for one package. + * + * Each package has its own file and is the only build that writes it, so a + * nightly installed beside the release never changes what the release's + * launcher runs, and the reverse. The version is read from + * `.taskless/taskless.json` when the launcher runs rather than written into + * it, so the file's bytes are the same for every version of its package and an + * upgrade only moves a line of JSON. + * + * The template's comments describe what it does and nothing more. An agent + * that reads the file is judging whether to run it, and a comment framing the + * launcher as a way past a permission check is what gets it refused. + */ + +/** The package names a launcher can run. */ +// ast-grep-ignore: no-unrouted-cli-invocation +export const LAUNCHER_PACKAGES = [ + "@taskless/cli", + "@taskless/cli-nightly", +] as const; + +export type LauncherPackage = (typeof LAUNCHER_PACKAGES)[number]; + +/** The token the template spells its package with. */ +const PACKAGE_PLACEHOLDER = "%(TASKLESS_PACKAGE)s"; + +/** The launcher's file name for a package. */ +export function launcherFileName(packageName: LauncherPackage): string { + return packageName.endsWith("-nightly") ? "cli-nightly.mjs" : "cli.mjs"; +} + +/** The launcher's path relative to the project root, `/`-separated. */ +export function launcherPath(packageName: LauncherPackage): string { + return `.taskless/bin/${launcherFileName(packageName)}`; +} + +/** + * How an agent or a person runs a package's launcher from the project root: + * `./.taskless/bin/cli.mjs`. This exact spelling is what permission rules and + * the global trust entry allow. + */ +export function launcherInvocation(packageName: LauncherPackage): string { + return `./${launcherPath(packageName)}`; +} + +/** The launcher's full content for a package. */ +export function renderLauncher(packageName: LauncherPackage): string { + return template.replaceAll(PACKAGE_PLACEHOLDER, packageName); +} diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts new file mode 100644 index 00000000..22fb721f --- /dev/null +++ b/packages/cli/test/launcher.test.ts @@ -0,0 +1,229 @@ +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import process from "node:process"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + launcherFileName, + launcherInvocation, + renderLauncher, + type LauncherPackage, +} from "../src/install/launcher"; + +/** + * A stand-in for `npx` that reports what it was given. The launcher finds it + * on `PATH` exactly as it would find the real one. + */ +const FAKE_NPX = `#!/usr/bin/env node +const out = { + argv: process.argv.slice(2), + cwd: process.cwd(), + launcher: process.env.TASKLESS_LAUNCHER, +}; +process.stdout.write(JSON.stringify(out)); +if (process.env.FAKE_NPX_SIGNAL) process.kill(process.pid, process.env.FAKE_NPX_SIGNAL); +process.exit(Number(process.env.FAKE_NPX_EXIT ?? 0)); +`; + +let root: string; +let fakeBin: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "taskless-launcher-")); + fakeBin = join(root, "fake-bin"); + await mkdir(fakeBin); + await writeFile(join(fakeBin, "npx"), FAKE_NPX); + await chmod(join(fakeBin, "npx"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function install( + packageName: LauncherPackage, + manifest: unknown +): Promise { + const bin = join(root, ".taskless", "bin"); + await mkdir(bin, { recursive: true }); + if (manifest !== undefined) { + await writeFile( + join(root, ".taskless", "taskless.json"), + JSON.stringify(manifest) + ); + } + const path = join(bin, launcherFileName(packageName)); + await writeFile(path, renderLauncher(packageName)); + await chmod(path, 0o755); + return path; +} + +function launch( + path: string, + args: string[], + options: { cwd?: string; env?: Record } = {} +) { + return spawnSync(path, args, { + cwd: options.cwd ?? root, + encoding: "utf8", + env: { + ...process.env, + ...options.env, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + }, + }); +} + +function reported(stdout: string): { + argv: string[]; + cwd: string; + launcher: string; +} { + return JSON.parse(stdout) as { + argv: string[]; + cwd: string; + launcher: string; + }; +} + +describe.skipIf(process.platform === "win32")("project launcher", () => { + it("runs the release's recorded version with every argument", async () => { + const path = await install("@taskless/cli", { + install: { launchers: { "@taskless/cli": "0.13.0" } }, + }); + const result = launch(path, ["check", "src/a file.ts", "--json"]); + expect(result.status).toBe(0); + expect(reported(result.stdout).argv).toEqual([ + "--yes", + "@taskless/cli@0.13.0", + "check", + "src/a file.ts", + "--json", + ]); + }); + + it("names itself to the CLI it starts", async () => { + const path = await install("@taskless/cli-nightly", { + install: { + launchers: { "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }, + }, + }); + const result = launch(path, ["agent", "route"]); + expect(reported(result.stdout).launcher).toBe( + "./.taskless/bin/cli-nightly.mjs" + ); + expect(reported(result.stdout).argv[1]).toBe( + "@taskless/cli-nightly@0.14.0-20261020120000xabc1234" + ); + }); + + it("is not moved by a nightly install of the other package", async () => { + const path = await install("@taskless/cli", { + install: { + cliVersion: "0.14.0-20261020120000xabc1234", + launchers: { "@taskless/cli": "0.13.0" }, + }, + }); + expect(reported(launch(path, []).stdout).argv[1]).toBe( + "@taskless/cli@0.13.0" + ); + }); + + it("falls back to cliVersion only when it is its own package's", async () => { + const manifest = { install: { cliVersion: "0.13.0" } }; + const release = await install("@taskless/cli", manifest); + expect(reported(launch(release, []).stdout).argv[1]).toBe( + "@taskless/cli@0.13.0" + ); + + const nightly = await install("@taskless/cli-nightly", manifest); + const result = launch(nightly, []); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + }); + + it("reads the manifest beside it when run from a subdirectory", async () => { + const path = await install("@taskless/cli", { + install: { launchers: { "@taskless/cli": "0.13.0" } }, + }); + const subdirectory = join(root, "src"); + await mkdir(subdirectory); + const result = launch(path, ["check"], { cwd: subdirectory }); + expect(result.status).toBe(0); + expect(reported(result.stdout).argv[1]).toBe("@taskless/cli@0.13.0"); + expect(reported(result.stdout).cwd).toMatch(/src$/); + }); + + it("passes the CLI's exit status back", async () => { + const path = await install("@taskless/cli", { + install: { launchers: { "@taskless/cli": "0.13.0" } }, + }); + expect( + launch(path, ["check"], { env: { FAKE_NPX_EXIT: "1" } }).status + ).toBe(1); + }); + + it("ends by the same signal that ended the CLI", async () => { + const path = await install("@taskless/cli", { + install: { launchers: { "@taskless/cli": "0.13.0" } }, + }); + const result = launch(path, ["check"], { + env: { FAKE_NPX_SIGNAL: "SIGTERM" }, + }); + expect(result.signal).toBe("SIGTERM"); + }); + + it.each([ + ["no manifest", undefined], + ["a manifest that does not parse", "{"], + ["no version for its package", { install: { launchers: {} } }], + [ + "a version that is not a version", + { install: { launchers: { "@taskless/cli": "latest & calc" } } }, + ], + ])("runs nothing and says why with %s", async (_label, manifest) => { + const path = await install( + "@taskless/cli", + typeof manifest === "string" ? undefined : manifest + ); + if (typeof manifest === "string") { + await writeFile(join(root, ".taskless", "taskless.json"), manifest); + } + const result = launch(path, ["check"]); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(".taskless/taskless.json"); + expect(result.stderr).toContain("npx @taskless/cli@latest"); + }); +}); + +describe("launcher source", () => { + it.each(["@taskless/cli", "@taskless/cli-nightly"] as const)( + "%s starts with a node shebang and names its own package", + (packageName) => { + const source = renderLauncher(packageName); + expect(source.startsWith("#!/usr/bin/env node\n")).toBe(true); + expect(source).toContain(`const PACKAGE = "${packageName}";`); + expect(source).not.toContain("%("); + } + ); + + it("describes itself plainly", () => { + const comments = renderLauncher("@taskless/cli") + .split("\n") + .filter((line) => line.trimStart().startsWith("//")) + .join("\n"); + expect(comments).toContain(".taskless/taskless.json"); + expect(comments).toContain("@taskless/cli"); + expect(comments).not.toMatch(/bypass|classifier|auto mode/i); + }); + + it("is spelled from the project root", () => { + expect(launcherInvocation("@taskless/cli")).toBe("./.taskless/bin/cli.mjs"); + expect(launcherInvocation("@taskless/cli-nightly")).toBe( + "./.taskless/bin/cli-nightly.mjs" + ); + }); +}); From e73f3591aea99a87055845b1e68206cd6ea03989 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 16:56:09 -0700 Subject: [PATCH 04/15] feat(install): write the project launcher and name it in the skill init writes .taskless/bin/cli.mjs (or cli-nightly.mjs), records each package's version under install.launchers, restores an edited launcher and says so, and renders the skill's invocation to the launcher. The skill now says to run Taskless commands from the directory containing .taskless/. --- .../pinned-cli-wrapper/specs/cli-init/spec.md | 7 +- openspec/changes/pinned-cli-wrapper/tasks.md | 12 +- packages/cli/src/commands/init.ts | 31 ++++- packages/cli/src/filesystem/manifest.ts | 6 + packages/cli/src/install/canonical.ts | 7 +- packages/cli/src/install/install.ts | 21 ++++ packages/cli/src/install/launcher.ts | 100 ++++++++++++++- packages/cli/src/install/state.ts | 12 ++ packages/cli/src/util/invocation.ts | 10 +- packages/cli/src/wizard/index.ts | 10 +- packages/cli/test/apply-install-plan.test.ts | 5 +- packages/cli/test/install-launcher.test.ts | 115 ++++++++++++++++++ .../cli/test/installed-documentation.test.ts | 5 +- .../nightly/install-launcher-nightly.test.ts | 91 ++++++++++++++ .../stub-description-invocation.test.ts | 9 +- skills/taskless/SKILL.md | 6 + 16 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 packages/cli/test/install-launcher.test.ts create mode 100644 packages/cli/test/nightly/install-launcher-nightly.test.ts diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md b/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md index ecc5b276..cb7a187f 100644 --- a/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md +++ b/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md @@ -64,7 +64,7 @@ The CLI SHALL install skill content using a canonical-store-plus-stub model rath The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to this build's launcher, `./.taskless/bin/cli.mjs` for a release build and `./.taskless/bin/cli-nightly.mjs` for a nightly, so the rendered skill does not change between versions of one package. A `self` build, which installs no launcher, renders its path-form invocation. Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. -A reference stub's recovery command is not rendered to the launcher. It SHALL keep naming the build's pinned invocation, since it exists for when the canonical store, and the launcher with it, is missing. +A reference stub's recovery command is not rendered to the launcher. It SHALL keep the npx form it has today (unpinned `npx @taskless/cli init` for a release build), since it exists for when the canonical store, and the launcher with it, is missing. #### Scenario: Canonical skill content matches source @@ -77,10 +77,11 @@ A reference stub's recovery command is not rendered to the launcher. It SHALL ke - **WHEN** the release's `init` runs at `0.13.0` and then at `0.13.1`, and the skill source did not change between them - **THEN** the canonical `SKILL.md` SHALL be byte-identical after both -#### Scenario: A stub's recovery command keeps the pinned invocation +#### Scenario: A stub's recovery command keeps the npx form - **WHEN** the release's `init` at `0.13.0` writes a reference stub -- **THEN** the stub's recovery command SHALL name `npx @taskless/cli@0.13.0` +- **THEN** the stub's recovery command SHALL be `npx @taskless/cli init` +- **AND** SHALL NOT name `./.taskless/bin/cli.mjs` #### Scenario: The sources carry the placeholder, not the literal diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index 0b94ee90..a4253825 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -8,12 +8,12 @@ ## 2. Install -- [ ] 2.1 Add `install.launchers` to the manifest type and install state, keeping the other package's entry on write -- [ ] 2.2 Write this build's launcher (mode 0755) in the step that writes the canonical skill; skip for `self` builds -- [ ] 2.3 Rewrite a launcher whose bytes differ and name it as restored in the human summary and the `--json` envelope -- [ ] 2.4 Render the skill's `%(TASKLESS_CLI)s` to the launcher path; keep stub recovery commands on the pinned invocation -- [ ] 2.5 Add the project-root guidance to `skills/taskless/SKILL.md`: run from the directory containing `.taskless/`, change directory in its own call, and read "No such file or directory" from the launcher as a wrong directory, not a reason to call npx -- [ ] 2.6 Tests: release and nightly installs side by side, upgrade leaves launcher and skill bytes unchanged, edited launcher restored, `self` writes none +- [x] 2.1 Add `install.launchers` to the manifest type and install state, keeping the other package's entry on write +- [x] 2.2 Write this build's launcher (mode 0755) in the step that writes the canonical skill; skip for `self` builds +- [x] 2.3 Rewrite a launcher whose bytes differ and name it as restored in the human summary and the `--json` envelope +- [x] 2.4 Render the skill's `%(TASKLESS_CLI)s` to the launcher path; keep stub recovery commands on the pinned invocation +- [x] 2.5 Add the project-root guidance to `skills/taskless/SKILL.md`: run from the directory containing `.taskless/`, change directory in its own call, and read "No such file or directory" from the launcher as a wrong directory, not a reason to call npx +- [x] 2.6 Tests: release and nightly installs side by side, upgrade leaves launcher and skill bytes unchanged, edited launcher restored, `self` writes none ## 3. Permissions diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 171290fb..27fbbb86 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -12,7 +12,8 @@ import { getEmbeddedSkills, } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; -import type { InstallMode } from "../install/state"; +import type { InstallMode, InstallState } from "../install/state"; +import { describeLauncherWrite, type LauncherWrite } from "../install/launcher"; import { getReloadNotice, versionMoved } from "../install/reload-notice"; import { getUpgradeTrailer } from "../install/upgrade-trailer"; import { @@ -144,6 +145,9 @@ export const initCommand = defineCommand({ // whether its user-level entry is present. Read only: the batch // install never writes outside the project. Empty when none apply. globalTrust: result.globalTrust, + // This build's launcher and what the run did to it; `null` for a + // build that installs none. + launcher: result.launcher, // Absent when nothing ran, so a caller distinguishes "the tree was // rewritten" from "nothing happened" by presence, never by reading // empty arrays out of it. @@ -345,6 +349,8 @@ async function runNonInteractive( targets: TargetOutcome[]; /** Whether a migration ran or any target wrote or removed anything. */ changed: boolean; + /** This build's launcher, or `null` for a build that has none. */ + launcher: LauncherWrite | null; /** Pins in `package.json`, CI, or hooks that run an older CLI than this. */ pinnedCli: PinnedCli[]; /** Harness permission files this run created, edited, or left alone. */ @@ -474,6 +480,11 @@ async function runNonInteractive( } } + const { launcher } = result; + if (launcher !== null && launcher.state !== "unchanged") { + log(describeLauncherWrite(launcher)); + } + const selected = plan.targets .filter((target) => target.mode === "reference") .map((target) => target.dir); @@ -501,12 +512,15 @@ async function runNonInteractive( targets, permissions, globalTrust, + launcher, // A version move rewrites `install.cliVersion` in the manifest, which is // a tracked file, so it is a change even when every skill byte matched. changed: migrated !== undefined || targets.some((target) => targetChanged(target)) || permissions.some((edit) => edit.skipped === null) || + (launcher !== null && launcher.state !== "unchanged") || + launcherVersionMoved(previousState, result.state) || versionMoved({ previousCliVersion, cliVersion }), pinnedCli: await findStalePins(cwd, cliVersion), }; @@ -523,3 +537,18 @@ function groupValuesByTarget( } return map; } + +/** + * Whether this run moved a launcher's recorded version. It lives in the + * tracked manifest, so it is a change even when no file bytes moved. + */ +function launcherVersionMoved( + previous: InstallState, + next: InstallState +): boolean { + const before = previous.launchers ?? {}; + const after = next.launchers ?? {}; + return Object.entries(after).some( + ([name, version]) => before[name] !== version + ); +} diff --git a/packages/cli/src/filesystem/manifest.ts b/packages/cli/src/filesystem/manifest.ts index 8c4128f6..cc888cad 100644 --- a/packages/cli/src/filesystem/manifest.ts +++ b/packages/cli/src/filesystem/manifest.ts @@ -45,6 +45,12 @@ export interface TasklessInstallManifest { * touched. Read defensively: a hand-edited manifest can hold anything. */ permissions?: Record; + /** + * The version each package's `.taskless/bin/` launcher runs, keyed by + * package name. Separate from `cliVersion`, which is whichever build + * installed last, so a nightly install never moves the release's launcher. + */ + launchers?: Record; } /** What Taskless recorded writing to one harness permission file. */ diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 5fad7e56..e5dca9ad 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -12,6 +12,7 @@ import { } from "../util/invocation"; import { parseFrontmatter } from "./frontmatter"; import { renderTrustPlaceholders } from "./global-trust"; +import { skillInvocation } from "./launcher"; /** * The Taskless-owned namespace that holds canonical skill content. No tool @@ -40,7 +41,7 @@ export function canonicalSkillPath(name: string): string { * Write a skill's full content to the canonical store at * `.taskless/skills//SKILL.md`. The canonical store is the single source * of truth. The source spells the CLI as `%(TASKLESS_CLI)s`, rendered here to - * this build's invocation, and its global trust entry and label likewise (see + * this build's launcher, `./.taskless/bin/cli.mjs` (see {@link skillInvocation}), and its global trust entry and label likewise (see * {@link renderTrustPlaceholders}); for `dev`/`self`/nightly builds a build * notice is also prepended (see {@link renderInvocationPlaceholder} / * {@link withCliBuildNotice}). @@ -56,7 +57,9 @@ export async function writeCanonicalSkill( const changed = await writeIfChanged( path, withCliBuildNotice( - renderTrustPlaceholders(renderInvocationPlaceholder(content)) + renderTrustPlaceholders( + renderInvocationPlaceholder(content, skillInvocation()) + ) ) ); return { path, changed }; diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 4182d1b6..a1bd7946 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -27,6 +27,11 @@ import { type InstallMode, type InstallState, } from "./state"; +import { + buildLauncherPackage, + writeLauncher, + type LauncherWrite, +} from "./launcher"; // Skill files embedded at build time via Vite import.meta.glob const skillFiles: Record = import.meta.glob( @@ -361,6 +366,8 @@ export interface ApplyInstallOptions { export interface ApplyInstallResult { state: InstallState; + /** This build's launcher, or `null` for a build that has none. */ + launcher: LauncherWrite | null; writtenSkills: Array<{ target: string; skill: string }>; removedSkills: Array<{ target: string; skill: string }>; removedCommands: Array<{ target: string; command: string }>; @@ -482,8 +489,16 @@ export async function applyInstallPlan( ): Promise { const previousState = await readInstallState(cwd); + // Each package records only its own launcher's version and keeps the + // other's, so the release and a nightly never move each other. + const launcherPackage = buildLauncherPackage(); + const launchers = { ...previousState.launchers }; + if (launcherPackage !== undefined) { + launchers[launcherPackage] = options.cliVersion; + } const nextState: InstallState = { cliVersion: options.cliVersion, + ...(Object.keys(launchers).length > 0 ? { launchers } : {}), targets: planToStateTargets(plan), }; @@ -530,10 +545,16 @@ export async function applyInstallPlan( } } + const launcher = + launcherPackage === undefined + ? null + : await writeLauncher(cwd, launcherPackage); + await writeInstallState(cwd, nextState); return { state: nextState, + launcher, writtenSkills, removedSkills, removedCommands, diff --git a/packages/cli/src/install/launcher.ts b/packages/cli/src/install/launcher.ts index 4cddafa3..4e24f630 100644 --- a/packages/cli/src/install/launcher.ts +++ b/packages/cli/src/install/launcher.ts @@ -1,4 +1,8 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + import template from "../../assets/launcher/launcher.mjs?raw"; +import { buildInvocation } from "../util/invocation"; /** * The project launcher: a small Node script under `.taskless/bin/` that runs @@ -16,10 +20,15 @@ import template from "../../assets/launcher/launcher.mjs?raw"; * launcher as a way past a permission check is what gets it refused. */ -/** The package names a launcher can run. */ -// ast-grep-ignore: no-unrouted-cli-invocation +/** + * The package names a launcher can run. Package names, not invocations: each + * launcher names its own package in every build, which is what keeps the + * release's and a nightly's apart. + */ export const LAUNCHER_PACKAGES = [ + // ast-grep-ignore: no-unrouted-cli-invocation "@taskless/cli", + // ast-grep-ignore: no-unrouted-cli-invocation "@taskless/cli-nightly", ] as const; @@ -51,3 +60,90 @@ export function launcherInvocation(packageName: LauncherPackage): string { export function renderLauncher(packageName: LauncherPackage): string { return template.replaceAll(PACKAGE_PLACEHOLDER, packageName); } + +/** + * The package this build's launcher runs, or `undefined` for a build that has + * none. A `self` build runs from a path in this checkout, not from a published + * package, so there is no version for a launcher to pin. + */ +export function buildLauncherPackage(): LauncherPackage | undefined { + switch (__TASKLESS_BUILD_TARGET__) { + case "prod": { + return LAUNCHER_PACKAGES[0]; + } + case "nightly": { + return LAUNCHER_PACKAGES[1]; + } + default: { + return undefined; + } + } +} + +/** + * What an install did to this build's launcher. `restored` means a launcher + * was there with other bytes: it runs whatever it contains under any rule that + * allows its path, so an edit is put back and said out loud. + */ +export interface LauncherWrite { + path: string; + state: "unchanged" | "created" | "restored"; +} + +/** + * Write a package's launcher into `cwd`, only when its bytes differ, and make + * it executable either way: a checkout can drop the mode bit while keeping the + * content. + */ +export async function writeLauncher( + cwd: string, + packageName: LauncherPackage +): Promise { + const path = launcherPath(packageName); + const absolute = join(cwd, path); + const content = renderLauncher(packageName); + let existing: string | undefined; + try { + existing = await readFile(absolute, "utf8"); + } catch { + // Absent or unreadable: written below. + } + if (existing !== content) { + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + } + await chmod(absolute, 0o755); + return { + path, + state: + existing === undefined + ? "created" + : existing === content + ? "unchanged" + : "restored", + }; +} + +/** + * What the installed skill names the CLI as: this build's launcher, so the + * rendered skill is the same for every version of a package and agents run the + * spelling harnesses allow by path. A build with no launcher names its own + * invocation, as before. + */ +export function skillInvocation(): string { + const packageName = buildLauncherPackage(); + return packageName === undefined + ? buildInvocation() + : launcherInvocation(packageName); +} + +/** + * The line an install prints for a launcher it wrote. A restored launcher is + * named as such: it had been edited, and it runs under any rule that allows + * its path. + */ +export function describeLauncherWrite(write: LauncherWrite): string { + return write.state === "restored" + ? `Restored ${write.path} (it had been edited)` + : `Created ${write.path}`; +} diff --git a/packages/cli/src/install/state.ts b/packages/cli/src/install/state.ts index 68f25e9a..24fa406d 100644 --- a/packages/cli/src/install/state.ts +++ b/packages/cli/src/install/state.ts @@ -28,6 +28,8 @@ export interface InstallTargetRecord { export interface InstallState { cliVersion?: string; + /** The version each package's launcher runs, keyed by package name. */ + launchers?: Record; targets: Record; } @@ -65,8 +67,15 @@ function toInstallState( }; } } + // Read defensively: a hand-edited manifest can hold anything here, and the + // launcher itself refuses a value that is not a version. + const launchers: Record = {}; + for (const [name, version] of Object.entries(install?.launchers ?? {})) { + if (typeof version === "string") launchers[name] = version; + } return { cliVersion: install?.cliVersion, + ...(Object.keys(launchers).length > 0 ? { launchers } : {}), targets, }; } @@ -82,6 +91,9 @@ function toInstallManifest(state: InstallState): TasklessInstallManifest { } const manifest: TasklessInstallManifest = { targets }; if (state.cliVersion) manifest.cliVersion = state.cliVersion; + if (state.launchers && Object.keys(state.launchers).length > 0) { + manifest.launchers = { ...state.launchers }; + } return manifest; } diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index f63bcba2..286f04f2 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -36,7 +36,8 @@ export const INVOCATION_PLACEHOLDER = "%(TASKLESS_CLI)s"; /** * Render {@link INVOCATION_PLACEHOLDER} in an installed skill or command body - * to this build's invocation. + * to `invocation`, this build's own by default. The canonical skill passes the + * project launcher instead, for a build that has one. * * The same token the recipes use, so a reader of either source sees one * spelling. It is substituted as an exact token rather than rendered through @@ -49,8 +50,11 @@ export const INVOCATION_PLACEHOLDER = "%(TASKLESS_CLI)s"; * carried a skill telling its agent to run the release package. A token * either matches exactly or a test on the source notices it is missing. */ -export function renderInvocationPlaceholder(content: string): string { - return content.replaceAll(INVOCATION_PLACEHOLDER, __TASKLESS_CLI__); +export function renderInvocationPlaceholder( + content: string, + invocation: string = __TASKLESS_CLI__ +): string { + return content.replaceAll(INVOCATION_PLACEHOLDER, invocation); } /** diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index b7024e53..35132260 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -12,6 +12,7 @@ import { getEmbeddedSkills, planToStateTargets, } from "../install/install"; +import { describeLauncherWrite } from "../install/launcher"; import { globalTrustWarnings, needsWrite, @@ -114,7 +115,14 @@ export async function runWizard( onNotice: (message) => log.info(message), }); const cliVersion = getCliVersion(); - await applyInstallPlan(options.cwd, plan, { cliVersion }); + const { launcher } = await applyInstallPlan(options.cwd, plan, { + cliVersion, + }); + if (launcher !== null && launcher.state !== "unchanged") { + (launcher.state === "restored" ? log.warn : log.info)( + describeLauncherWrite(launcher) + ); + } await syncPermissions(options.cwd, { ...permissionOptions, write: true }); // Confirmed with the project edits, in the summary's one question. if (globalTrustPreview.some((outcome) => needsWrite(outcome))) { diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index a9368600..f45adff8 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -18,6 +18,7 @@ import { getEmbeddedSkills, } from "../src/install/install"; import { renderTrustPlaceholders } from "../src/install/global-trust"; +import { skillInvocation } from "../src/install/launcher"; import { renderInvocationPlaceholder } from "../src/util/invocation"; import { isShimStub, stubRecoveryInvocation } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; @@ -66,7 +67,7 @@ describe("applyInstallPlan", () => { const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); // Canonical store holds the source with `%(TASKLESS_CLI)s` rendered to - // this build's invocation, and the trust placeholders to this build's + // this build's launcher, and the trust placeholders to this build's // entry, which are the only substitutions it makes. const canonical = await readFile( join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), @@ -74,7 +75,7 @@ describe("applyInstallPlan", () => { ); expect(canonical).toBe( renderTrustPlaceholders( - renderInvocationPlaceholder(tasklessSkill().content) + renderInvocationPlaceholder(tasklessSkill().content, skillInvocation()) ) ); diff --git a/packages/cli/test/install-launcher.test.ts b/packages/cli/test/install-launcher.test.ts new file mode 100644 index 00000000..cf20ba29 --- /dev/null +++ b/packages/cli/test/install-launcher.test.ts @@ -0,0 +1,115 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedSkills, +} from "../src/install/install"; +import { renderLauncher } from "../src/install/launcher"; +import { readInstallState, writeInstallState } from "../src/install/state"; + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-install-launcher-")); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +function install(cliVersion: string) { + return applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion } + ); +} + +const LAUNCHER = join(".taskless", "bin", "cli.mjs"); +const SKILL = join(".taskless", "skills", "taskless", "SKILL.md"); + +describe("the release's install writes its launcher", () => { + it("creates an executable cli.mjs and records the release's version", async () => { + const result = await install("0.13.0"); + + expect(result.launcher).toEqual({ + path: ".taskless/bin/cli.mjs", + state: "created", + }); + expect(await readFile(join(cwd, LAUNCHER), "utf8")).toBe( + renderLauncher("@taskless/cli") + ); + const { mode } = await stat(join(cwd, LAUNCHER)); + expect(mode & 0o111).not.toBe(0); + await expect( + stat(join(cwd, ".taskless", "bin", "cli-nightly.mjs")) + ).rejects.toThrow(); + + const state = await readInstallState(cwd); + expect(state.launchers).toEqual({ "@taskless/cli": "0.13.0" }); + expect(state.cliVersion).toBe("0.13.0"); + }); + + it("leaves the launcher and the skill byte-identical across an upgrade", async () => { + await install("0.13.0"); + const launcher = await readFile(join(cwd, LAUNCHER), "utf8"); + const skill = await readFile(join(cwd, SKILL), "utf8"); + + const result = await install("0.13.1"); + + expect(result.launcher?.state).toBe("unchanged"); + expect(await readFile(join(cwd, LAUNCHER), "utf8")).toBe(launcher); + expect(await readFile(join(cwd, SKILL), "utf8")).toBe(skill); + const { launchers } = await readInstallState(cwd); + expect(launchers).toEqual({ + "@taskless/cli": "0.13.1", + }); + }); + + it("restores an edited launcher and says so", async () => { + await install("0.13.0"); + await writeFile(join(cwd, LAUNCHER), "#!/usr/bin/env node\n// edited\n"); + + const result = await install("0.13.0"); + + expect(result.launcher?.state).toBe("restored"); + expect(await readFile(join(cwd, LAUNCHER), "utf8")).toBe( + renderLauncher("@taskless/cli") + ); + }); + + it("keeps the nightly's recorded version", async () => { + await mkdir(join(cwd, ".taskless")); + await writeInstallState(cwd, { + cliVersion: "0.14.0-20261020120000xabc1234", + launchers: { "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }, + targets: {}, + }); + + await install("0.13.0"); + + const { launchers } = await readInstallState(cwd); + expect(launchers).toEqual({ + "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234", + "@taskless/cli": "0.13.0", + }); + }); + + it("names the launcher in the installed skill", async () => { + await install("0.13.0"); + const skill = await readFile(join(cwd, SKILL), "utf8"); + expect(skill).toContain("./.taskless/bin/cli.mjs agent"); + expect(skill).toContain("the directory that contains `.taskless/`"); + }); +}); diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 30c66884..beeb8b75 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -135,7 +135,7 @@ describe("this repository's own installed Taskless docs", () => { expect(body).not.toContain("npx @taskless/cli"); }); - it("renders the placeholder to this build's invocation at install, and nothing else", async () => { + it("renders the placeholder to this build's launcher at install, and nothing else", async () => { const cwd = await mkdtemp(join(tmpdir(), "taskless-placeholder-")); try { await applyInstallPlan( @@ -147,7 +147,8 @@ describe("this repository's own installed Taskless docs", () => { join(".taskless", "skills", "taskless", "SKILL.md"), ]) { const installed = await readFile(join(cwd, relative), "utf8"); - expect(installed, relative).toContain(`${buildInvocation()} agent`); + expect(installed, relative).toContain("./.taskless/bin/cli.mjs agent"); + expect(installed, relative).not.toContain(buildInvocation()); expect(installed, relative).not.toContain("%("); } } finally { diff --git a/packages/cli/test/nightly/install-launcher-nightly.test.ts b/packages/cli/test/nightly/install-launcher-nightly.test.ts new file mode 100644 index 00000000..5e90b759 --- /dev/null +++ b/packages/cli/test/nightly/install-launcher-nightly.test.ts @@ -0,0 +1,91 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedSkills, +} from "../../src/install/install"; +import { renderLauncher } from "../../src/install/launcher"; +import { readInstallState, writeInstallState } from "../../src/install/state"; +import { getCliVersion } from "../../src/wizard/intro"; + +/** + * Runs under the `nightly` vitest project, whose build target is `nightly`. + * The release's launcher and the nightly's must never touch each other, and a + * prod define cannot express the nightly side of that. + */ + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-install-launcher-nightly-")); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +const RELEASE_LAUNCHER = join(".taskless", "bin", "cli.mjs"); +const NIGHTLY_LAUNCHER = join(".taskless", "bin", "cli-nightly.mjs"); + +describe("a nightly install beside the release", () => { + it("writes only cli-nightly.mjs and keeps the release's launcher and version", async () => { + await mkdir(join(cwd, ".taskless", "bin"), { recursive: true }); + const release = renderLauncher("@taskless/cli"); + await writeFile(join(cwd, RELEASE_LAUNCHER), release); + await writeInstallState(cwd, { + cliVersion: "0.13.0", + launchers: { "@taskless/cli": "0.13.0" }, + targets: {}, + }); + + const version = getCliVersion(); + const result = await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: version } + ); + + expect(result.launcher).toEqual({ + path: ".taskless/bin/cli-nightly.mjs", + state: "created", + }); + expect(await readFile(join(cwd, RELEASE_LAUNCHER), "utf8")).toBe(release); + expect(await readFile(join(cwd, NIGHTLY_LAUNCHER), "utf8")).toBe( + renderLauncher("@taskless/cli-nightly") + ); + const { mode } = await stat(join(cwd, NIGHTLY_LAUNCHER)); + expect(mode & 0o111).not.toBe(0); + + const state = await readInstallState(cwd); + expect(state.launchers).toEqual({ + "@taskless/cli": "0.13.0", + "@taskless/cli-nightly": version, + }); + expect(state.cliVersion).toBe(version); + }); + + it("names its own launcher in the installed skill", async () => { + await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: getCliVersion() } + ); + const skill = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(skill).toContain("./.taskless/bin/cli-nightly.mjs agent"); + expect(skill).not.toContain("./.taskless/bin/cli.mjs"); + }); +}); diff --git a/packages/cli/test/nightly/stub-description-invocation.test.ts b/packages/cli/test/nightly/stub-description-invocation.test.ts index e826cbfe..f22fcc00 100644 --- a/packages/cli/test/nightly/stub-description-invocation.test.ts +++ b/packages/cli/test/nightly/stub-description-invocation.test.ts @@ -80,14 +80,15 @@ describe("installing a nightly writes a stub description that names no CLI packa expect(skillDescription).not.toMatch(/@taskless\/cli/); }); - it("still correctly pins the canonical file's own invocation", async () => { + it("still names the nightly's own launcher in the canonical file", async () => { await install(); - // Contrast case: the canonical `.taskless/` copy IS rewritten per build, + // Contrast case: the canonical `.taskless/` copy IS rendered per build, // which is what makes the stub's staleness invisible without this test — - // the canonical file looks completely correct. + // the canonical file looks completely correct. It names the nightly's + // launcher, which runs the pinned nightly recorded for it. const canonical = await readFile(canonicalSkillPath(), "utf8"); - expect(canonical).toContain(buildInvocation()); + expect(canonical).toContain("./.taskless/bin/cli-nightly.mjs agent"); expect(canonical).not.toContain("npx @taskless/cli agent"); }); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 525e2307..384a0705 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -164,6 +164,12 @@ and follow the copy it returns. ## Run each Taskless command on its own +Run them from the project root, the directory that contains `.taskless/` +(in a monorepo, the nearest one above the files you are working on). If your +shell is elsewhere, `cd` there in a separate call first. "No such file or +directory" from `%(TASKLESS_CLI)s` means the shell is in the wrong directory: +change directory and rerun it, and do not call `npx` or `node` instead. + Run every `%(TASKLESS_CLI)s` command as its own shell call, exactly as written. Do not chain it with other commands, and add no separators, pipes, or redirections around it. The commands Taskless hands you run the same in From 6e410e9e286109d5d53e5bf6946a5b2128c3eac3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:00:11 -0700 Subject: [PATCH 05/15] feat(install): allow the project launcher in each harness's permission file Each harness gets a version-free rule for this build's launcher beside its pinned npx rule. Launcher paths count as Taskless rules of their package, so they are kept across upgrades, removed with their package, and a hand-added colon form is replaced. A deny on the launcher leaves the file alone. --- openspec/changes/pinned-cli-wrapper/tasks.md | 6 +- packages/cli/src/commands/init.ts | 7 +- packages/cli/src/install/launcher.ts | 7 +- packages/cli/src/install/permissions.ts | 103 ++++++++++++----- packages/cli/src/wizard/index.ts | 6 +- packages/cli/test/migrated-envelope.test.ts | 5 +- packages/cli/test/permissions.test.ts | 110 +++++++++++++++++++ packages/cli/test/wizard-integration.test.ts | 1 + 8 files changed, 209 insertions(+), 36 deletions(-) diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index a4253825..ebd0bf9f 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -17,9 +17,9 @@ ## 3. Permissions -- [ ] 3.1 Confirm with a local run what the docs leave open: that Cursor CLI compares a path-valued `Shell(./.taskless/bin/cli.mjs)` base literally rather than by basename, and that neither Cursor surface normalizes `./`. Codex is settled from source (literal, whole-token, `./` required); drop any harness that fails and update the spec table -- [ ] 3.2 Add the launcher rule per harness beside the invocation rules, and map `cli.mjs` / `cli-nightly.mjs` to their packages when classifying Taskless rules -- [ ] 3.3 Tests: launcher rule written, not reported as an edit on upgrade, removed with its package when a harness is deselected, other package's launcher rule kept +- [ ] 3.1 Confirm with a local run what the docs leave open: that Cursor CLI compares a path-valued `Shell(./.taskless/bin/cli.mjs)` base literally rather than by basename, and that neither Cursor surface normalizes `./`. Codex is settled from source and confirmed locally with `codex execpolicy check` on 2026-10-19 (matches `./.taskless/bin/cli.mjs` with and without arguments; not `cli.mjsx`, not `.taskless/bin/cli.mjs`, not the nightly's launcher); drop any harness that fails and update the spec table +- [x] 3.2 Add the launcher rule per harness beside the invocation rules, and map `cli.mjs` / `cli-nightly.mjs` to their packages when classifying Taskless rules +- [x] 3.3 Tests: launcher rule written, not reported as an edit on upgrade, removed with its package when a harness is deselected, other package's launcher rule kept ## 4. Global trust entry diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 27fbbb86..787987c7 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,7 +13,11 @@ import { } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; import type { InstallMode, InstallState } from "../install/state"; -import { describeLauncherWrite, type LauncherWrite } from "../install/launcher"; +import { + buildLauncherInvocation, + describeLauncherWrite, + type LauncherWrite, +} from "../install/launcher"; import { getReloadNotice, versionMoved } from "../install/reload-notice"; import { getUpgradeTrailer } from "../install/upgrade-trailer"; import { @@ -492,6 +496,7 @@ async function runNonInteractive( selected, codexDetected: detected.some((tool) => tool.name === "Codex"), invocation: buildInvocation(), + launcher: buildLauncherInvocation(), write: true, }); for (const line of describePermissionEdits(permissions)) log(line); diff --git a/packages/cli/src/install/launcher.ts b/packages/cli/src/install/launcher.ts index 4e24f630..e211cffa 100644 --- a/packages/cli/src/install/launcher.ts +++ b/packages/cli/src/install/launcher.ts @@ -131,9 +131,14 @@ export async function writeLauncher( * invocation, as before. */ export function skillInvocation(): string { + return buildLauncherInvocation() ?? buildInvocation(); +} + +/** This build's launcher invocation, or `undefined` when it installs none. */ +export function buildLauncherInvocation(): string | undefined { const packageName = buildLauncherPackage(); return packageName === undefined - ? buildInvocation() + ? undefined : launcherInvocation(packageName); } diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index 3680f890..152f5baf 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -97,6 +97,8 @@ interface Harness { interface EditContext { invocation: string; + /** This build's launcher, allowed beside the invocation, when it has one. */ + launcher?: string; /** Whether Taskless created this file, so may delete it once it is empty. */ created: boolean; } @@ -359,18 +361,28 @@ function globToRegExp(glob: string): RegExp { return new RegExp(`^${source}${trailing ? "( .*)?" : ""}$`, "s"); } +/** The commands this install allows: the invocation and its launcher. */ +function allowedCommands(context: EditContext): string[] { + return context.launcher === undefined + ? [context.invocation] + : [context.invocation, context.launcher]; +} + /** Commands a deny covering Taskless would match. */ -function sampleCommands(invocation: string): string[] { - return [invocation, `${invocation} agent ok`]; +function sampleCommands(context: EditContext): string[] { + return allowedCommands(context).flatMap((command) => [ + command, + `${command} agent ok`, + ]); } -function coversAny(glob: string, invocation: string): boolean { +function coversAny(glob: string, context: EditContext): boolean { const pattern = globToRegExp(glob); - return sampleCommands(invocation).some((command) => pattern.test(command)); + return sampleCommands(context).some((command) => pattern.test(command)); } /** Claude Code: `Bash`, `Bash(*)`, `Bash(npx *)`, `Bash(npx:*)`, … */ -function claudeDenies(root: unknown, invocation: string): boolean { +function claudeDenies(root: unknown, context: EditContext): boolean { const deny = valueAt(root, ["permissions", "deny"]); if (!Array.isArray(deny)) return false; return deny.some((rule) => { @@ -381,24 +393,26 @@ function claudeDenies(root: unknown, invocation: string): boolean { const glob = match[1].endsWith(":*") ? `${match[1].slice(0, -2)} *` : match[1]; - return coversAny(glob, invocation); + return coversAny(glob, context); }); } /** Cursor CLI: `Shell(base)` or `Shell(base:argsGlob)`. */ -function cursorCliDenies(root: unknown, invocation: string): boolean { +function cursorCliDenies(root: unknown, context: EditContext): boolean { const deny = valueAt(root, ["permissions", "deny"]); if (!Array.isArray(deny)) return false; - const [base, ...rest] = invocation.split(" "); - return deny.some((rule) => { - if (typeof rule !== "string") return false; - const match = /^Shell\(([^:)]*)(?::(.*))?\)$/s.exec(rule); - if (match === null || match[1] !== base) return false; - if (match[2] === undefined) return true; - const args = globToRegExp(match[2]); - return [rest.join(" "), `${rest.join(" ")} agent ok`].some((a) => - args.test(a) - ); + return allowedCommands(context).some((command) => { + const [base, ...rest] = command.split(" "); + return deny.some((rule) => { + if (typeof rule !== "string") return false; + const match = /^Shell\(([^:)]*)(?::(.*))?\)$/s.exec(rule); + if (match === null || match[1] !== base) return false; + if (match[2] === undefined) return true; + const args = globToRegExp(match[2]); + return [rest.join(" "), `${rest.join(" ")} agent ok`].some((a) => + args.test(a) + ); + }); }); } @@ -412,12 +426,21 @@ function cursorCliDenies(root: unknown, invocation: string): boolean { const TASKLESS_COMMAND = /^(?:npx|pnpm dlx) (@taskless\/cli(?:-nightly)?)(?:@[^\s]+)?$/; +/** + * A package's project launcher, spelled from the project root. The rule for + * it names no version, and it belongs to the package whose launcher it is. + */ +const LAUNCHER_COMMAND = /^\.\/\.taskless\/bin\/(cli|cli-nightly)\.mjs$/; + /** * The Taskless package `command` runs, less a trailing ` *` glob, or * `undefined` when it does not run the Taskless CLI. */ function tasklessPackage(command: string): string | undefined { - return TASKLESS_COMMAND.exec(command.replace(/ \*$/, ""))?.[1]; + const bare = command.replace(/ \*$/, ""); + const launcher = LAUNCHER_COMMAND.exec(bare)?.[1]; + if (launcher !== undefined) return `@taskless/${launcher}`; + return TASKLESS_COMMAND.exec(bare)?.[1]; } /** Whether `command`, less a trailing ` *` glob, runs the Taskless CLI. */ @@ -445,10 +468,16 @@ function isTasklessClaudeRule(rule: string): boolean { return inner !== undefined && isTasklessCommand(inner.replace(/:\*$/, "")); } -/** Cursor CLI: `Shell(:)`, with or without a trailing ` *`. */ +/** + * Cursor CLI: `Shell(:)`, with or without a trailing ` *`, or a + * bare `Shell()`, which is how a launcher with no arguments is allowed. + */ function isTasklessCursorCliRule(rule: string): boolean { - const match = /^Shell\(([^:)]+):(.*)\)$/s.exec(rule); - return match !== null && isTasklessCommand(`${match[1]} ${match[2]}`); + const match = /^Shell\(([^:)]+)(?::(.*))?\)$/s.exec(rule); + if (match === null) return false; + return isTasklessCommand( + match[2] === undefined ? (match[1] ?? "") : `${match[1]} ${match[2]}` + ); } /** @@ -458,14 +487,17 @@ function isTasklessCursorCliRule(rule: string): boolean { */ export function commandOfRule(rule: string): string { const bash = /^Bash\((.*)\)$/s.exec(rule)?.[1]; - const shell = /^Shell\(([^:)]+):(.*)\)$/s.exec(rule); + const shell = /^Shell\(([^:)]+)(?::(.*))?\)$/s.exec(rule); const pattern = /^prefix_rule\(pattern = (\[.*\]), decision = "allow"\)$/.exec(rule)?.[1]; let command = rule; if (bash !== undefined) { command = bash.replace(/:\*$/, ""); } else if (shell !== null) { - command = `${shell[1] ?? ""} ${shell[2] ?? ""}`; + command = + shell[2] === undefined + ? (shell[1] ?? "") + : `${shell[1] ?? ""} ${shell[2]}`; } else if (pattern !== undefined) { const words: unknown = parseJson(pattern); if (Array.isArray(words)) command = words.join(" "); @@ -511,7 +543,7 @@ const claudeCode: Harness = { foundInList(text, ["permissions", "allow"], isTasklessClaudeRule), edit: (text, add, remove, context) => editStringList(text, ["permissions", "allow"], add, remove, context, (r) => - claudeDenies(r, context.invocation) + claudeDenies(r, context) ), }; @@ -527,6 +559,9 @@ const cursorCli: Harness = { ...fixed(CURSOR_CLI_FILE), rules: (invocation) => { const [base, ...rest] = invocation.split(" "); + // A bare base allows the command with any arguments, which is the whole + // rule for a launcher: it takes no fixed arguments of its own. + if (rest.length === 0) return [`Shell(${base ?? ""})`]; const args = rest.join(" "); return [`Shell(${base ?? ""}:${args})`, `Shell(${base ?? ""}:${args} *)`]; }, @@ -534,7 +569,7 @@ const cursorCli: Harness = { foundInList(text, ["permissions", "allow"], isTasklessCursorCliRule), edit: (text, add, remove, context) => editStringList(text, ["permissions", "allow"], add, remove, context, (r) => - cursorCliDenies(r, context.invocation) + cursorCliDenies(r, context) ), }; @@ -596,7 +631,7 @@ const openCode: Harness = { entry.effect === "deny" && (entry.action === "shell" || entry.action === "*") && typeof entry.resource === "string" && - coversAny(entry.resource, context.invocation) + coversAny(entry.resource, context) ) ) { return { kind: "skip", reason: "denies this command" }; @@ -646,8 +681,7 @@ const openCode: Harness = { add.length > 0 && isRecord(bash) && Object.entries(bash).some( - ([glob, action]) => - action === "deny" && coversAny(glob, context.invocation) + ([glob, action]) => action === "deny" && coversAny(glob, context) ) ) { return { kind: "skip", reason: "denies this command" }; @@ -801,6 +835,11 @@ export interface SyncPermissionsOptions { codexDetected: boolean; /** The invocation to allow: this build's pinned CLI. */ invocation: string; + /** + * This build's launcher, `./.taskless/bin/cli.mjs`, allowed beside the + * invocation. Absent for a build that installs none. + */ + launcher?: string; /** `false` reports what would change and writes nothing. */ write: boolean; } @@ -835,7 +874,12 @@ export async function syncPermissions( for (const harness of harnessesFor(options.selected, options.codexDetected)) { desired.set(await harness.locate(cwd), { harness, - rules: harness.rules(options.invocation), + rules: [ + ...harness.rules(options.invocation), + ...(options.launcher === undefined + ? [] + : harness.rules(options.launcher)), + ], }); } @@ -866,6 +910,7 @@ export async function syncPermissions( const created = recorded?.created ?? text === undefined; const result = harness.edit(text, add, remove, { invocation: options.invocation, + ...(options.launcher === undefined ? {} : { launcher: options.launcher }), created, }); if (result.kind === "skip") { diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 35132260..8f23e54e 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -12,7 +12,10 @@ import { getEmbeddedSkills, planToStateTargets, } from "../install/install"; -import { describeLauncherWrite } from "../install/launcher"; +import { + buildLauncherInvocation, + describeLauncherWrite, +} from "../install/launcher"; import { globalTrustWarnings, needsWrite, @@ -82,6 +85,7 @@ export async function runWizard( .map((target) => target.dir), codexDetected: detected.some((tool) => tool.name === "Codex"), invocation: buildInvocation(), + launcher: buildLauncherInvocation(), }; const permissionPreview = await syncPermissions(options.cwd, { ...permissionOptions, diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index 0e56d0a8..f2dacec9 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -200,7 +200,10 @@ describe("who migrates, and who refuses", () => { { file: ".claude/settings.json", created: true, - allowed: [`Bash(npx @taskless/cli@${envelope.cliVersion.installed} *)`], + allowed: [ + `Bash(npx @taskless/cli@${envelope.cliVersion.installed} *)`, + "Bash(./.taskless/bin/cli.mjs *)", + ], removed: [], skipped: null, }, diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index c56108c4..e20cfd09 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -699,3 +699,113 @@ describe("commandOfRule", () => { ).toBe(V1); }); }); + +describe("launcher rules", () => { + const RL = "./.taskless/bin/cli.mjs"; + const NL = "./.taskless/bin/cli-nightly.mjs"; + + it("allows the launcher beside the invocation in Claude Code", async () => { + const edits = await sync({ selected: [".claude"], launcher: RL }); + expect(edits[0]?.allowed).toEqual([`Bash(${V1} *)`, `Bash(${RL} *)`]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`, `Bash(${RL} *)`] }, + }); + }); + + it("writes each harness's launcher form", async () => { + await sync({ + selected: [".cursor", ".opencode", ".agents"], + codexDetected: true, + launcher: RL, + }); + expect(await json(".cursor/permissions.json")).toEqual({ + terminalAllowlist: [V1, RL], + }); + expect( + (await json(".cursor/cli.json")) as { permissions: { allow: string[] } } + ).toEqual({ + permissions: { + allow: [ + "Shell(npx:@taskless/cli@0.13.0)", + "Shell(npx:@taskless/cli@0.13.0 *)", + `Shell(${RL})`, + ], + }, + }); + expect(await json(".opencode/opencode.json")).toEqual({ + permission: { bash: { [`${V1} *`]: "allow", [`${RL} *`]: "allow" } }, + }); + expect(await text(".codex/rules/taskless.rules")).toContain( + `prefix_rule(pattern = ["${RL}"], decision = "allow")` + ); + }); + + it("is not an edit on an upgrade", async () => { + await sync({ selected: [".claude"], launcher: RL }); + const edits = await sync({ + selected: [".claude"], + invocation: V2, + launcher: RL, + }); + expect(edits).toEqual([ + { + file: ".claude/settings.json", + created: false, + allowed: [`Bash(${V2} *)`], + removed: [`Bash(${V1} *)`], + skipped: null, + }, + ]); + }); + + it("keeps the other package's launcher rule", async () => { + await sync({ selected: [".claude"], launcher: RL }); + await sync({ selected: [".claude"], invocation: N1, launcher: NL }); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { + allow: [ + `Bash(${V1} *)`, + `Bash(${RL} *)`, + `Bash(${N1} *)`, + `Bash(${NL} *)`, + ], + }, + }); + }); + + it("removes the launcher rule with its package when the harness is deselected", async () => { + await sync({ selected: [".claude"], launcher: RL }); + await sync({ selected: [".claude"], invocation: N1, launcher: NL }); + const edits = await sync({ selected: [], launcher: RL }); + expect(edits[0]?.removed).toEqual([`Bash(${V1} *)`, `Bash(${RL} *)`]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${N1} *)`, `Bash(${NL} *)`] }, + }); + }); + + it("replaces a hand-added colon form of its launcher", async () => { + await put( + ".claude/settings.json", + JSON.stringify({ permissions: { allow: [`Bash(${RL}:*)`] } }) + ); + const edits = await sync({ selected: [".claude"], launcher: RL }); + expect(edits[0]?.removed).toEqual([`Bash(${RL}:*)`]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`, `Bash(${RL} *)`] }, + }); + }); + + it("leaves a file that denies the launcher alone", async () => { + await put( + ".claude/settings.json", + JSON.stringify({ permissions: { deny: [`Bash(${RL} *)`] } }) + ); + const edits = await sync({ selected: [".claude"], launcher: RL }); + expect(edits[0]?.skipped).toBe("denies this command"); + }); + + it("reads a launcher rule as the command it allows", () => { + expect(commandOfRule(`Shell(${RL})`)).toBe(RL); + expect(commandOfRule(`Bash(${RL} *)`)).toBe(RL); + }); +}); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 13053055..fc4963b9 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -392,6 +392,7 @@ describe("runWizard end-to-end", () => { ) as { permissions: { allow: string[] } }; expect(settings.permissions.allow).toEqual([ expect.stringMatching(/^Bash\(npx @taskless\/cli@\S+ \*\)$/), + "Bash(./.taskless/bin/cli.mjs *)", ]); }); From af9175ce35d90263a34a598956401733a6922121 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:03:21 -0700 Subject: [PATCH 06/15] feat(init): cover the launcher in the global trust entry and fold duplicates Each package's autoMode entry also allows its own launcher from the project root. More than one string under a build's label is stale even when one is current; the wizard keeps the first, replaced in place, and removes the rest. The outcome carries how many strings carry the label, and the warning says so. --- openspec/changes/pinned-cli-wrapper/tasks.md | 10 +-- packages/cli/src/install/global-trust.ts | 84 +++++++++++++------ packages/cli/test/global-trust.test.ts | 79 ++++++++++++++++- packages/cli/test/init-no-interactive.test.ts | 1 + 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index ebd0bf9f..30c4527c 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -23,11 +23,11 @@ ## 4. Global trust entry -- [ ] 4.1 Extend each package's entry text to cover its own launcher from the project root -- [ ] 4.2 Read more than one string under this build's label as stale, even when one is current -- [ ] 4.3 Fold duplicates in the wizard's write: replace the first in place, remove later ones, keep the other label's and unlabelled strings in order; one `REPLACE (global):` line -- [ ] 4.4 Say in the non-interactive `SKIPPED (global):` output how many strings carry the label and that the wizard folds them -- [ ] 4.5 Update the entry tests and the installed-skill snapshot; confirm an old entry reads as stale; cover duplicates, a duplicated current entry, decline, and `--no-interactive` leaving the file byte-identical +- [x] 4.1 Extend each package's entry text to cover its own launcher from the project root +- [x] 4.2 Read more than one string under this build's label as stale, even when one is current +- [x] 4.3 Fold duplicates in the wizard's write: replace the first in place, remove later ones, keep the other label's and unlabelled strings in order; one `REPLACE (global):` line +- [x] 4.4 Say in the non-interactive `SKIPPED (global):` output how many strings carry the label and that the wizard folds them +- [x] 4.5 Update the entry tests and the installed-skill snapshot; confirm an old entry reads as stale; cover duplicates, a duplicated current entry, decline, and `--no-interactive` leaving the file byte-identical ## 5. Settings report colors diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts index 2555184c..b23ab03a 100644 --- a/packages/cli/src/install/global-trust.ts +++ b/packages/cli/src/install/global-trust.ts @@ -53,7 +53,8 @@ export interface GlobalTrustText { function trustText( packageName: string, label: string, - exampleVersion: string + exampleVersion: string, + launcher: string ): GlobalTrustText { return { packageName, @@ -67,7 +68,11 @@ function trustText( "same command line also sets a variable, changes or creates a " + "directory, or redirects the call's output to a file; every other " + "command on the line is judged on its own. Dist-tags such as @latest " + - "or @nightly, version ranges, and unversioned calls are not covered.", + "or @nightly, version ranges, and unversioned calls are not covered. " + + `Running ${launcher} from the project root, the directory that ` + + "contains .taskless/, is allowed with any arguments: it is the " + + "project's launcher for this package, and runs the exact version of " + + `${packageName} the project recorded in .taskless/taskless.json.`, }; } @@ -75,14 +80,16 @@ export const RELEASE_TRUST = trustText( // ast-grep-ignore: no-unrouted-cli-invocation "@taskless/cli", "Taskless CLI:", - "1.2.3" + "1.2.3", + "./.taskless/bin/cli.mjs" ); export const NIGHTLY_TRUST = trustText( // ast-grep-ignore: no-unrouted-cli-invocation "@taskless/cli-nightly", "Taskless CLI nightly:", - "1.2.3-20260101000000xabcdef0" + "1.2.3-20260101000000xabcdef0", + "./.taskless/bin/cli-nightly.mjs" ); /** @@ -167,6 +174,12 @@ export interface GlobalTrustOutcome { state: "present" | "absent" | "stale" | "written" | "skipped"; /** Why the file was left alone, or `null` when it was not. */ skipped: string | null; + /** + * How many strings in the list carry this build's label, read before any + * write. More than one is stale even when one is current, and the wizard + * folds them into one. + */ + labelled: number; } export interface SyncGlobalTrustOptions { @@ -187,8 +200,8 @@ async function readText(path: string): Promise { } type Inspection = - | { kind: "present" } - | { kind: "absent" | "stale"; text: string } + | { kind: "present"; labelled: number } + | { kind: "absent" | "stale"; text: string; labelled: number } | { kind: "skip"; reason: string }; /** Whether the file holds the entry, and the edited text when it does not. */ @@ -211,23 +224,31 @@ function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { return { kind: "absent", text: set(current, [...trust.key], [...trust.seed]), + labelled: 0, }; } if (!Array.isArray(list)) { return { kind: "skip", reason: `${trust.key.join(".")} is not a list` }; } - const index = list.findIndex( - (item) => typeof item === "string" && item.startsWith(BUILD_TRUST.label) + const indexes = list.flatMap((item, index) => + typeof item === "string" && item.startsWith(BUILD_TRUST.label) + ? [index] + : [] ); - if (index !== -1) { - if (list[index] === GLOBAL_TRUST_ENTRY) return { kind: "present" }; - // Replaced where it sits, so the user's ordering survives. A second - // labelled string is left alone: deleting from a user's list is more - // than the one line the summary shows. - return { - kind: "stale", - text: set(current, [...trust.key, index], GLOBAL_TRUST_ENTRY), - }; + const [first, ...later] = indexes; + if (first !== undefined) { + if (later.length === 0 && list[first] === GLOBAL_TRUST_ENTRY) { + return { kind: "present", labelled: 1 }; + } + // Every string under this label folds into one: the first is replaced + // where it sits, so the user's ordering survives, and the rest are + // removed, last first so the earlier indexes still point where they did. + // The label is ours, so nothing under it is anyone else's to keep. + let text = set(current, [...trust.key, first], GLOBAL_TRUST_ENTRY); + for (const index of later.toReversed()) { + text = set(text, [...trust.key, index], undefined); + } + return { kind: "stale", text, labelled: indexes.length }; } // An existing list without `$defaults` is the user replacing the defaults on // purpose, so it is appended to as it stands. @@ -236,6 +257,7 @@ function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { text: set(current, [...trust.key, list.length], GLOBAL_TRUST_ENTRY, { isArrayInsertion: true, }), + labelled: 0, }; } @@ -257,16 +279,22 @@ export async function syncGlobalTrust( const base = { harness: trust.harness, file, key: trust.key.join(".") }; const result = inspect(trust, await readText(file)); if (result.kind === "skip") { - outcomes.push({ ...base, state: "skipped", skipped: result.reason }); + outcomes.push({ + ...base, + state: "skipped", + skipped: result.reason, + labelled: 0, + }); continue; } + const { labelled } = result; if (result.kind === "present" || !options.write) { - outcomes.push({ ...base, state: result.kind, skipped: null }); + outcomes.push({ ...base, state: result.kind, skipped: null, labelled }); continue; } await mkdir(dirname(file), { recursive: true }); await writeFile(file, result.text, "utf8"); - outcomes.push({ ...base, state: "written", skipped: null }); + outcomes.push({ ...base, state: "written", skipped: null, labelled }); } return outcomes; } @@ -315,13 +343,15 @@ export function globalTrustWarnings( const list = `the top-level \`${outcome.key}\` list in ${display(outcome.file, home)}`; const wizard = `run \`${installer}\` with no arguments in a terminal`; const fix = - outcome.state === "stale" - ? `The \`${BUILD_TRUST.label}\` entry in ${list} is out of date. Replace it with this one, or ${wizard}, which offers to replace it` - : `To trust Taskless, add this entry to ${list}` + - (outcome.state === "skipped" - ? ` (left alone: ${outcome.skipped})` - : "") + - `, after "$defaults", or ${wizard}, which offers to add it`; + outcome.state === "stale" && outcome.labelled > 1 + ? `${String(outcome.labelled)} strings in ${list} begin \`${BUILD_TRUST.label}\`. Replace them with this one entry, or ${wizard}, which folds them into one` + : outcome.state === "stale" + ? `The \`${BUILD_TRUST.label}\` entry in ${list} is out of date. Replace it with this one, or ${wizard}, which offers to replace it` + : `To trust Taskless, add this entry to ${list}` + + (outcome.state === "skipped" + ? ` (left alone: ${outcome.skipped})` + : "") + + `, after "$defaults", or ${wizard}, which offers to add it`; return ( `${outcome.harness} may refuse Taskless commands that the project rule allows: its safety classifier overrules project allow rules. ` + `${fix}:\n ${GLOBAL_TRUST_ENTRY}` diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts index 875a15a2..baee708c 100644 --- a/packages/cli/test/global-trust.test.ts +++ b/packages/cli/test/global-trust.test.ts @@ -93,6 +93,7 @@ describe("syncGlobalTrust", () => { key: "autoMode.allow", state: "written", skipped: null, + labelled: 0, }, ]); expect(JSON.parse(await read())).toEqual({ @@ -181,19 +182,76 @@ describe("syncGlobalTrust", () => { }); }); - it("replaces only the first labelled entry", async () => { + it("folds every labelled entry into one, at the first one's place", async () => { await put( JSON.stringify({ - autoMode: { allow: ["Taskless CLI: one", "Taskless CLI: two"] }, + autoMode: { + allow: [ + "$defaults", + GLOBAL_TRUST_ENTRY, + "Internal Registry: ...", + "Taskless CLI: pasted by hand", + NIGHTLY_TRUST.entry, + ], + }, }) ); + const [before] = await sync(false); + expect(before).toMatchObject({ + state: "stale", + labelled: 2, + }); await sync(true); expect(JSON.parse(await read())).toEqual({ - autoMode: { allow: [GLOBAL_TRUST_ENTRY, "Taskless CLI: two"] }, + autoMode: { + allow: [ + "$defaults", + GLOBAL_TRUST_ENTRY, + "Internal Registry: ...", + NIGHTLY_TRUST.entry, + ], + }, }); }); + it("reads a duplicated current entry as stale and leaves one copy", async () => { + await put( + JSON.stringify({ + autoMode: { + allow: ["Taskless CLI: old", GLOBAL_TRUST_ENTRY, GLOBAL_TRUST_ENTRY], + }, + }) + ); + const [before] = await sync(false); + expect(before?.state).toBe("stale"); + await sync(true); + + expect(JSON.parse(await read())).toEqual({ + autoMode: { allow: [GLOBAL_TRUST_ENTRY] }, + }); + const [after] = await sync(false); + expect(after).toMatchObject({ + state: "present", + labelled: 1, + }); + }); + + it("leaves duplicates in place when not writing", async () => { + const before = JSON.stringify({ + autoMode: { allow: [GLOBAL_TRUST_ENTRY, GLOBAL_TRUST_ENTRY] }, + }); + await put(before); + await sync(false); + expect(await read()).toBe(before); + }); + + it("covers its own launcher and not the nightly's", () => { + expect(GLOBAL_TRUST_ENTRY).toContain("./.taskless/bin/cli.mjs"); + expect(GLOBAL_TRUST_ENTRY).not.toContain("cli-nightly.mjs"); + expect(NIGHTLY_TRUST.entry).toContain("./.taskless/bin/cli-nightly.mjs"); + }); + it("leaves the nightly's entry alone", async () => { await put( JSON.stringify({ @@ -319,6 +377,21 @@ describe("rendering", () => { expect(warning).toContain(GLOBAL_TRUST_ENTRY); }); + it("says how many strings carry the label when there are duplicates", async () => { + await put( + JSON.stringify({ + autoMode: { allow: [GLOBAL_TRUST_ENTRY, "Taskless CLI: pasted"] }, + }) + ); + const [warning] = globalTrustWarnings( + await sync(false), + "npx @taskless/cli", + home + ); + expect(warning).toContain("2 strings"); + expect(warning).toContain("folds them into one"); + }); + it("is silent once the entry is present", async () => { await sync(true); const outcomes = await sync(false); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index ea6de78b..4e641d81 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -568,6 +568,7 @@ describe("taskless init (the batch install)", () => { key: "autoMode.allow", state: "absent", skipped: null, + labelled: 0, }, ]); expect(stderr).toContain("autoMode.allow"); From 3eb1fbddc8ede290ff51c44b0b9c6da4bfe1e66e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:06:06 -0700 Subject: [PATCH 07/15] feat(init): color settings edits as migrations ALLOW green, REMOVE in the default color, REPLACE yellow, SKIPPED red. Red is reserved for an edit the user has to make by hand. A missing or stale global trust entry after init now prints as a red SKIPPED (global) line. The init recipe documents the launcher field, the labelled count, and the new line. --- openspec/changes/pinned-cli-wrapper/tasks.md | 6 ++-- packages/cli/src/agent/init.md | 29 ++++++++++++++------ packages/cli/src/install/global-trust.ts | 7 ++++- packages/cli/src/install/permissions.ts | 12 +++----- packages/cli/src/install/settings-tone.ts | 28 +++++++++++++++++++ packages/cli/src/wizard/steps/summary.ts | 9 +++--- packages/cli/test/global-trust.test.ts | 18 ++++++++++++ packages/cli/test/permissions.test.ts | 29 ++++++++++++++++++++ packages/cli/test/settings-tone.test.ts | 24 ++++++++++++++++ 9 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/install/settings-tone.ts create mode 100644 packages/cli/test/settings-tone.test.ts diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index 30c4527c..745cbfdb 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -31,9 +31,9 @@ ## 5. Settings report colors -- [ ] 5.1 Recolor the shared tones: `ALLOW` green, `REMOVE` default, `REPLACE` yellow, `SKIPPED` red, in the wizard summary and in printed output -- [ ] 5.2 Print the non-interactive global outcome as a red `SKIPPED (global):` line with the entry and the installer command, replacing the warning -- [ ] 5.3 Update the summary, permission-report, and global-trust tests and snapshots +- [x] 5.1 Recolor the shared tones: `ALLOW` green, `REMOVE` default, `REPLACE` yellow, `SKIPPED` red, in the wizard summary and in printed output +- [x] 5.2 Print the non-interactive global outcome as a red `SKIPPED (global):` line with the entry and the installer command, replacing the warning +- [x] 5.3 Update the summary, permission-report, and global-trust tests and snapshots ## 6. Recipes diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 52c68893..31346ff5 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v10) +# Topic: init (CLI v%(CLI_VERSION)s / topic v11) %(ONE_COMMAND_PER_CALL)s @@ -55,8 +55,10 @@ first step, not the whole job. ], "globalTrust": [ { "harness": "Claude Code", "file": "/Users/you/.claude/settings.json", - "key": "autoMode.allow", "state": "absent", "skipped": null } - ] + "key": "autoMode.allow", "state": "absent", "skipped": null, + "labelled": 0 } + ], + "launcher": { "path": ".taskless/bin/cli.mjs", "state": "unchanged" } } ``` - `cliVersion.previous` is `null` on a project with no recorded @@ -65,7 +67,8 @@ first step, not the whole job. removed there, by name. `mode: "canonical"` is the `.taskless/` store; `reference` is a tool directory holding stubs. - `changed` is `true` when a migration ran, any target list is - non-empty, or `cliVersion` moved (that rewrites + non-empty, the launcher was created or restored, or `cliVersion` or a + launcher's recorded version moved (both rewrite `.taskless/taskless.json`). When it is `false` AND `pinnedCli` is empty, stop here: nothing to commit, nothing to reconcile, nothing to bump. @@ -91,24 +94,32 @@ first step, not the whole job. a classifier that overrules project rules. Each entry is that harness's user-level settings file and whether its Taskless entry is `present`, `absent`, `stale` (an entry under this CLI's label, such - as `Taskless CLI:` for the release, with older text), or `skipped` + as `Taskless CLI:` for the release, with older text, or more than one + string under that label: `labelled` says how many), or `skipped` (with the reason). `init` only reads it; it is never written by this command, and you must not write it either. When it is `absent` or `stale`, tell the user that their agent may refuse Taskless commands the project rule allows, and that the - printed warning names the entry to add, or to replace the old one - with, themselves, or that running `%(TASKLESS_CLI)s` with no + printed `SKIPPED (global):` line names the entry to add, or to + replace the old ones with, themselves, or that running `%(TASKLESS_CLI)s` with no arguments, in their own terminal, offers to do it (`init` itself never asks). Without `--json`, the same facts print as prose: a per-target summary, each permission edit as `Edited ` with `ALLOW:` and `REMOVE:` - lines (or `SKIPPED:` with the reason), a warning for each missing or - stale global trust entry, then a trailer naming the directories that + lines (or `SKIPPED:` with the reason), a `SKIPPED (global):` line for + each missing or stale global trust entry, then a trailer naming the directories that changed and, after a version move, pointing at `update`, then a notice naming each stale pin and the version to move it to. + - `launcher` is this CLI's project launcher, `.taskless/bin/cli.mjs` + (`cli-nightly.mjs` for a nightly), which runs the version recorded + for it in `.taskless/taskless.json`. `state` is `created`, + `restored` (it had been edited, and was put back), or `unchanged`. + It is `null` for a build that installs none. The launcher is a file + to commit with the rest of `.taskless/`. + 2. **Tell the user what needs committing.** Every `targets[].dir` with a non-empty list, plus `.taskless/`, any `migrated.files` entries, and every `permissions[].file` without `skipped`, now holds changes that diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts index b23ab03a..1b260cb0 100644 --- a/packages/cli/src/install/global-trust.ts +++ b/packages/cli/src/install/global-trust.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { isProductionInvocation } from "../util/invocation"; import { isRecord } from "../util/is-record"; import { parseJson, set, valueAt } from "./permissions"; +import { SETTINGS_TONE } from "./settings-tone"; /** * User-level trust for harnesses whose safety classifier overrules a project @@ -328,7 +329,8 @@ export function describeGlobalTrust( } /** - * A warning for each harness still missing its entry, or holding a stale one. + * A `SKIPPED (global):` report for each harness still missing its entry, or + * holding a stale one, after `init` finishes. * `installer` is the command a person runs, with no arguments, to get the * wizard that offers it. */ @@ -352,7 +354,10 @@ export function globalTrustWarnings( ? ` (left alone: ${outcome.skipped})` : "") + `, after "$defaults", or ${wizard}, which offers to add it`; + // A skip: an edit only the user can make, which is why it is red. The + // label leads, so it reads the same way as every other settings edit. return ( + `${SETTINGS_TONE.skip("SKIPPED (global):")} ${display(outcome.file, home)} (${outcome.key})\n` + `${outcome.harness} may refuse Taskless commands that the project rule allows: its safety classifier overrules project allow rules. ` + `${fix}:\n ${GLOBAL_TRUST_ENTRY}` ); diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index 152f5baf..f4306386 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -1,7 +1,6 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import chalk from "chalk"; import { applyEdits, findNodeAtLocation, @@ -12,10 +11,7 @@ import { type ParseError, } from "jsonc-parser"; -// Sets `chalk.level` from the real terminal on import. This module renders -// colour, so it establishes that itself. -import "../util/color"; - +import { SETTINGS_TONE } from "./settings-tone"; import { readManifest, writeManifest, @@ -963,15 +959,15 @@ export function describePermissionEdits( const lines: string[] = []; for (const edit of edits) { if (edit.skipped !== null) { - lines.push(chalk.yellow(`SKIPPED: ${edit.file} ${edit.skipped}`)); + lines.push(SETTINGS_TONE.skip(`SKIPPED: ${edit.file} ${edit.skipped}`)); continue; } lines.push(`${edit.created ? "Created" : "Edited"} ${edit.file}`); for (const rule of edit.allowed) { - lines.push(` ${chalk.green(`ALLOW: ${rule}`)}`); + lines.push(` ${SETTINGS_TONE.allow(`ALLOW: ${rule}`)}`); } for (const rule of edit.removed) { - lines.push(` ${chalk.red(`REMOVE: ${rule}`)}`); + lines.push(` ${SETTINGS_TONE.remove(`REMOVE: ${rule}`)}`); } } return lines; diff --git a/packages/cli/src/install/settings-tone.ts b/packages/cli/src/install/settings-tone.ts new file mode 100644 index 00000000..ccc7facc --- /dev/null +++ b/packages/cli/src/install/settings-tone.ts @@ -0,0 +1,28 @@ +import chalk from "chalk"; + +// Sets `chalk.level` from the real terminal on import. Every module that +// renders a settings edit reaches its colour through here. +import "../util/color"; + +/** + * How a settings edit is coloured, wherever it is shown: the wizard's summary, + * `init`'s printed output, project files and the user's own settings alike. + * + * Read as a settings migration. An addition is what the reader needs to + * notice, so it is green. Removing Taskless's own outdated rules is expected, + * so it keeps the terminal's colour. A replacement changes an entry the user + * already has, worth a moment's attention, so it is yellow. A skip is an edit + * the user has to make by hand, because Taskless will not make it for them, + * and Taskless commands can be refused until they do: it is the only red. + * + * The labels carry the meaning on their own, so output reads the same with + * colour off. + */ +export const SETTINGS_TONE = { + allow: (text: string) => chalk.green(text), + remove: (text: string) => text, + replace: (text: string) => chalk.yellow(text), + skip: (text: string) => chalk.red(text), +} as const; + +export type SettingsTone = keyof typeof SETTINGS_TONE; diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index 2b829733..82a2e62d 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -13,6 +13,7 @@ import { } from "../../install/global-trust"; import { commandOfRule, type PermissionEdit } from "../../install/permissions"; import { wrap } from "../../install/notice-box"; +import { SETTINGS_TONE, type SettingsTone } from "../../install/settings-tone"; import type { InstallDiff } from "../../install/state"; import { ask } from "../ask"; @@ -64,7 +65,7 @@ export async function renderSummaryAndConfirm( : describeGlobalTrust(globalTrust.outcomes, globalTrust.home).map( ({ replace, what, where }): SettingsLine => ({ label: replace ? "REPLACE (global):" : "ALLOW (global):", - tone: "allow", + tone: replace ? "replace" : "allow", what, where, }) @@ -99,7 +100,7 @@ const SETTINGS_QUESTION = "Make these changes to your settings?"; /** One settings edit: what it allows or removes, and where. */ interface SettingsLine { label: string; - tone: "allow" | "remove" | "skip"; + tone: SettingsTone; what: string; where: string; } @@ -110,8 +111,6 @@ interface SettingsLine { */ const SUMMARY_COLUMNS = 76; -const TONE = { allow: chalk.green, remove: chalk.red, skip: chalk.yellow }; - /** * Labels padded to the widest one, so every statement starts in one column, * and each statement wrapped under itself, never mid-token: a nightly version @@ -128,7 +127,7 @@ function layoutSettings(entries: readonly SettingsLine[]): string[] { ]; const [first = "", ...rest] = body; lines.push( - `${TONE[entry.tone](entry.label)}${" ".repeat(width - entry.label.length)}${first}`, + `${SETTINGS_TONE[entry.tone](entry.label)}${" ".repeat(width - entry.label.length)}${first}`, ...rest.map((line) => `${indent}${line}`) ); } diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts index baee708c..b6c7f27b 100644 --- a/packages/cli/test/global-trust.test.ts +++ b/packages/cli/test/global-trust.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import chalk from "chalk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -392,6 +393,23 @@ describe("rendering", () => { expect(warning).toContain("folds them into one"); }); + it("leads with a red SKIPPED (global) line", async () => { + const level = chalk.level; + chalk.level = 1; + try { + const [warning] = globalTrustWarnings( + await sync(false), + "npx @taskless/cli", + home + ); + expect(warning?.split("\n")[0]).toBe( + `${chalk.red("SKIPPED (global):")} ~/.claude/settings.json (autoMode.allow)` + ); + } finally { + chalk.level = level; + } + }); + it("is silent once the entry is present", async () => { await sync(true); const outcomes = await sync(false); diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index e20cfd09..bc1aa96e 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -683,6 +683,35 @@ describe("describePermissionEdits", () => { chalk.level = level; } }); + + it("leaves REMOVE uncoloured and colours SKIPPED red", () => { + const level = chalk.level; + chalk.level = 1; + try { + const [, remove, skipped] = describePermissionEdits([ + { + file: ".claude/settings.json", + created: false, + allowed: [], + removed: [`Bash(${V1} *)`], + skipped: null, + }, + { + file: ".cursor/cli.json", + created: false, + allowed: [], + removed: [], + skipped: "denies this command", + }, + ]); + expect(remove).toBe(` REMOVE: Bash(${V1} *)`); + expect(skipped).toBe( + chalk.red("SKIPPED: .cursor/cli.json denies this command") + ); + } finally { + chalk.level = level; + } + }); }); describe("commandOfRule", () => { diff --git a/packages/cli/test/settings-tone.test.ts b/packages/cli/test/settings-tone.test.ts new file mode 100644 index 00000000..cd047778 --- /dev/null +++ b/packages/cli/test/settings-tone.test.ts @@ -0,0 +1,24 @@ +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { SETTINGS_TONE } from "../src/install/settings-tone"; + +let level: typeof chalk.level; + +beforeEach(() => { + level = chalk.level; + chalk.level = 1; +}); + +afterEach(() => { + chalk.level = level; +}); + +describe("settings edit colours", () => { + it("is green for an addition, plain for a removal, yellow for a replacement, and red only for a skip", () => { + expect(SETTINGS_TONE.allow("ALLOW")).toBe(chalk.green("ALLOW")); + expect(SETTINGS_TONE.remove("REMOVE")).toBe("REMOVE"); + expect(SETTINGS_TONE.replace("REPLACE")).toBe(chalk.yellow("REPLACE")); + expect(SETTINGS_TONE.skip("SKIPPED")).toBe(chalk.red("SKIPPED")); + }); +}); From af6775ea188f5b6711dadb37ae473aad4ba89ec0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:08:55 -0700 Subject: [PATCH 08/15] feat(agent): name the launcher in recipes and wire CI through it A recipe fetched through a project launcher names that launcher, and tells the agent to run it from the directory containing .taskless/; any other TASKLESS_LAUNCHER value is ignored. The ci recipe runs node .taskless/bin/cli.mjs with Node supplied by the job, so a repository without package.json can run it, and its template now sets Node 22 to match the CLI's engine floor. --- openspec/changes/pinned-cli-wrapper/tasks.md | 6 +- packages/cli/src/agent/ci.md | 59 +++++++++------- packages/cli/src/prompts/recipes.ts | 9 ++- packages/cli/src/util/package-manager.ts | 28 +++++++- packages/cli/test/agent-extensions.test.ts | 11 ++- packages/cli/test/agent-launcher.test.ts | 68 +++++++++++++++++++ packages/cli/test/package-manager.test.ts | 22 ++++++ packages/cli/test/prompts.test.ts | 25 +++++-- .../cli/test/recipe-cross-references.test.ts | 13 +--- 9 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 packages/cli/test/agent-launcher.test.ts diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/pinned-cli-wrapper/tasks.md index 745cbfdb..0d3a4d49 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/pinned-cli-wrapper/tasks.md @@ -37,9 +37,9 @@ ## 6. Recipes -- [ ] 6.1 Prefer an exact-match `TASKLESS_LAUNCHER` in `detectCliInvocation`; leave `@taskless/cli/prompts` untouched; add the project-root sentence to the run-each-command guidance when the invocation is a launcher -- [ ] 6.2 Rewrite the `ci` recipe to `node .taskless/bin/cli.mjs`, drop `%(PACKAGE_MANAGER_DLX)s`, require the job to supply Node, and bump the topic version -- [ ] 6.3 Tests: recipe through the launcher, through npx, and with an unexpected variable value; the `ci` recipe snapshot +- [x] 6.1 Prefer an exact-match `TASKLESS_LAUNCHER` in `detectCliInvocation`; leave `@taskless/cli/prompts` untouched; add the project-root sentence to the run-each-command guidance when the invocation is a launcher +- [x] 6.2 Rewrite the `ci` recipe to `node .taskless/bin/cli.mjs`, drop `%(PACKAGE_MANAGER_DLX)s`, require the job to supply Node, and bump the topic version +- [x] 6.3 Tests: recipe through the launcher, through npx, and with an unexpected variable value; the `ci` recipe snapshot ## 7. Verification and release diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 9961dbe9..95a9ce44 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,4 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v6) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v7) %(ONE_COMMAND_PER_CALL)s @@ -18,6 +18,15 @@ you recognize one not on the list, apply the same patterns. produces an always-green check that gives false confidence.) - A local `%(TASKLESS_CLI)s check` succeeds (or fails with real findings the user is OK with seeing in CI's first run). +- `.taskless/bin/cli.mjs` exists and is committed. It is the project + launcher `init` writes, and it runs the CLI version recorded in + `.taskless/taskless.json`, so CI runs the same version the project + was installed with and an upgrade reaches CI in the same commit that + updates `.taskless/`. If it is missing, the project was installed by + an older CLI: tell the user to run `%(TASKLESS_CLI)s init` and commit + the result first. If only `.taskless/bin/cli-nightly.mjs` exists, the + project runs a nightly; ask whether CI should run that nightly, and if + so use `cli-nightly.mjs` wherever this recipe says `cli.mjs`. - No auth required for CI: `check` is unauthenticated by default. (Optionally tokenized as a server-enforced backstop; see step 7.) @@ -113,14 +122,12 @@ Canonical paths: The reference template, translate the same shape (checkout with full history, set up Node, conditional check) for other CIs. -Substitute `%(PACKAGE_MANAGER_DLX)s` with the CI runner's own complete -invocation, `npx @taskless/cli`, `pnpm dlx @taskless/cli`, -`yarn dlx @taskless/cli`, or `bunx @taskless/cli`, picking the launcher -from `pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`. It is a whole command on -its own, exactly as the template uses it; never combine it with -`%(TASKLESS_CLI)s`. Which launcher the repository you are wiring up should -run is a different question from how this recipe was fetched, which is why -it stays a separate placeholder. +CI runs the launcher as `node .taskless/bin/cli.mjs`, from the +repository root, and never through `npx`, `pnpm dlx`, `yarn dlx`, or +`bunx`. `node ` rather than `./`: a CI checkout can lose the +executable bit, and a Windows runner does not honour the launcher's +shebang. This is a different spelling from the one you run locally, on +purpose; do not copy it into your own commands. ```yaml name: Taskless @@ -143,7 +150,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Taskless check run: | @@ -154,26 +161,31 @@ jobs: echo "No changed files." exit 0 fi - %(PACKAGE_MANAGER_DLX)s check $FILES + node .taskless/bin/cli.mjs check $FILES else - %(PACKAGE_MANAGER_DLX)s check + node .taskless/bin/cli.mjs check fi ``` Simplifications: -- Full-scan only → drop the `if`, just run `%(PACKAGE_MANAGER_DLX)s check`. +- Full-scan only → drop the `if`, just run `node .taskless/bin/cli.mjs check`. - Diff-scan only → remove the `push:` trigger. ### 6. Translate to other CIs The six universal steps: -1. Set up Node (v20 default; match the user's existing CI version). +1. Make Node available to the job (v22 or later; match the user's + existing CI version when it is newer): the CI system's Node setup + step, or a Node container image such as `node:22`. The Taskless job + needs nothing else from the project's own toolchain, and no + `package.json`, so in a Python, Go, or other non-Node repository it + still runs as its own job with its own image. 2. Fetch with full depth (or enough to reach the target branch). 3. Fetch the target branch. 4. Compute changed files with `git diff --name-only "origin/...HEAD"`. 5. Exit early if the diff is empty. -6. Call `%(PACKAGE_MANAGER_DLX)s check $FILES` for PR builds, or - `%(PACKAGE_MANAGER_DLX)s check` for main-branch builds. +6. Call `node .taskless/bin/cli.mjs check $FILES` for PR builds, or + `node .taskless/bin/cli.mjs check` for main-branch builds. YAML for GitHub/GitLab/Azure/Bitbucket; Groovy for Jenkins; different structure for CircleCI. The six steps stay the same. @@ -211,15 +223,14 @@ unauthenticated default remains fully supported. Also mention auth if the user explicitly asks to run authenticated commands (e.g. `rule create`/`rule improve`) in CI, uncommon. -### 8. Package manager caveats +### 8. Repositories that are not Node projects -- **pnpm**: `pnpm dlx` works but has slow cold starts. If the user's - pipeline already sets up pnpm, suggest adding `@taskless/cli` as a - dev dep and calling it via `pnpm taskless check`. That `taskless` is the - binary pnpm resolves from the wired repo's own `node_modules/.bin`, not - this CLI's invocation, so it is not `%(TASKLESS_CLI)s`. -- **Yarn v1 (classic)**: doesn't support `yarn dlx`. Use `npx`. -- **Bun**: `bunx` works. +The job needs Node and nothing else. When the repository's main +pipeline runs in an image without Node (a `python:3.12` image, for +example), keep Taskless in its own job with a Node image or setup step, +rather than installing Node into the main job. Do not add a +`package.json`, and do not add `@taskless/cli` as a dependency: the +launcher already pins the version through `.taskless/taskless.json`. ### 9. Report back diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 3879c2f7..6a0a7b55 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -582,9 +582,16 @@ stays your judgement.`; export function oneCommandPerCall(invocation?: string): string { const start = invocation === undefined ? "the Taskless invocation" : `\`${invocation}\``; + // A project launcher is a path relative to the project root, so it only + // resolves from there, and the `cd` that would get there cannot share its + // line. + const root = + invocation?.startsWith("./.taskless/bin/") === true + ? ` Run it from the directory that contains \`.taskless/\`; if your shell is elsewhere, \`cd\` there in a separate call first.` + : ""; return ( `Run each Taskless command as its own shell call. Start the line with ${start} ` + - `and chain nothing to it: no variable assignment, \`cd\`, \`mkdir\`, \`&&\`, \`;\`, pipe, redirect, or \`$(…)\`. ` + + `and chain nothing to it: no variable assignment, \`cd\`, \`mkdir\`, \`&&\`, \`;\`, pipe, redirect, or \`$(…)\`.${root} ` + `Read its output from the call's result, and write any file this recipe asks for with your file tools.` ); } diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index ec61a007..697f771e 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -187,12 +187,34 @@ export function readmeSpecifier(): string | undefined { export function detectCliInvocation( context: LauncherContext ): string | undefined { + // A project launcher started this process and named itself. Its spelling is + // what the reader should run next, since it is what the harness allows. + const launcher = projectLauncher(context); + if (launcher !== undefined) return launcher; const specifier = pinnedSpecifier(); // A path-form build is authoritative and complete on its own. if (specifier === undefined) return buildInvocation(); - const launcher = detectLauncher(context); - if (launcher === undefined) return undefined; - return `${LAUNCHER_COMMANDS[launcher]} ${specifier}`; + const detected = detectLauncher(context); + if (detected === undefined) return undefined; + return `${LAUNCHER_COMMANDS[detected]} ${specifier}`; +} + +/** + * The spellings a project launcher names itself with in `TASKLESS_LAUNCHER`. + * Only these are believed: the variable becomes text in a recipe an agent + * runs, so any other value is ignored rather than passed along. + */ +const PROJECT_LAUNCHERS = new Set([ + "./.taskless/bin/cli.mjs", + "./.taskless/bin/cli-nightly.mjs", +]); + +/** The project launcher that started this process, if one did. */ +function projectLauncher(context: LauncherContext): string | undefined { + const named = context.env.TASKLESS_LAUNCHER; + return named !== undefined && PROJECT_LAUNCHERS.has(named) + ? named + : undefined; } /** diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index 6b3d5f05..e9b9b6bd 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -206,15 +206,12 @@ describe("taskless agent ", () => { expect(result.stdout).toContain('"successCases"'); }); - it("renders %(PACKAGE_MANAGER_DLX)s as the agent-fill marker", async () => { + it("serves the ci recipe with the launcher command and no placeholders", async () => { const result = await runCli(["agent", "ci", "-d", cwd]); expect(result.exitCode).toBe(0); - // Sprintf substitutes the placeholder; the agent-fill marker should - // appear in its rendered form, never as the - // raw sprintf placeholder. - expect(result.stdout).not.toContain("%(PACKAGE_MANAGER_DLX)s"); - expect(result.stdout).not.toContain("{{PACKAGE_MANAGER_DLX}}"); - expect(result.stdout).toContain(""); + expect(result.stdout).not.toContain("%("); + expect(result.stdout).not.toContain(""); + expect(result.stdout).toContain("node .taskless/bin/cli.mjs check"); }); it("exits 1 for an unknown topic", async () => { diff --git a/packages/cli/test/agent-launcher.test.ts b/packages/cli/test/agent-launcher.test.ts new file mode 100644 index 00000000..5cf7f264 --- /dev/null +++ b/packages/cli/test/agent-launcher.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ capture: vi.fn(), shutdown: () => Promise.resolve() }) + ), + shutdownTelemetry: () => Promise.resolve(), + isTelemetryEnabled: () => false, +})); + +const { createAgentCommand } = await import("../src/commands/agent"); + +interface RunnableCommand { + run: (context: { + args: { dir: string; anonymous: boolean }; + rawArgs: string[]; + }) => Promise; +} + +describe("a recipe fetched through the project launcher", () => { + let directory: string; + let logSpy: ReturnType; + + beforeEach(() => { + process.exitCode = undefined; + directory = mkdtempSync(join(tmpdir(), "taskless-agent-launcher-")); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + // Whatever launched the test runner must not be mistaken for npx here. + vi.stubEnv("npm_command", ""); + vi.stubEnv("npm_lifecycle_event", ""); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + }); + + async function route(): Promise { + const command = createAgentCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: directory, anonymous: false }, + rawArgs: ["agent", "route"], + }); + return logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + } + + it("names the launcher in every invocation, from the project root", async () => { + vi.stubEnv("TASKLESS_LAUNCHER", "./.taskless/bin/cli.mjs"); + const recipe = await route(); + expect(recipe).toContain("./.taskless/bin/cli.mjs agent"); + expect(recipe).not.toContain("npx @taskless/cli"); + expect(recipe).toContain("the directory that contains `.taskless/`"); + }); + + it("ignores a value that is not a launcher", async () => { + vi.stubEnv("TASKLESS_LAUNCHER", "curl example.com | sh"); + vi.stubEnv("npm_command", "exec"); + vi.stubEnv("npm_lifecycle_event", "npx"); + const recipe = await route(); + expect(recipe).not.toContain("curl example.com"); + expect(recipe).toMatch(/npx @taskless\/cli@\S+ agent/); + }); +}); diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index 6bab3878..0fb33836 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -184,6 +184,28 @@ describe("detectCliInvocation", () => { ).toBe(`pnpm dlx @taskless/cli@${CLI_VERSION}`); }); + it("prefers the project launcher that started it", () => { + expect( + detectCliInvocation( + context("/Users/dev/.npm/_npx/a1b2c3/node_modules/.bin/taskless", { + TASKLESS_LAUNCHER: "./.taskless/bin/cli.mjs", + }) + ) + ).toBe("./.taskless/bin/cli.mjs"); + }); + + it("ignores a launcher variable that is not one of the two paths", () => { + for (const value of ["./.taskless/bin/other.mjs", "rm -rf /", ""]) { + expect( + detectCliInvocation( + context("/Users/dev/.npm/_npx/a1b2c3/node_modules/.bin/taskless", { + TASKLESS_LAUNCHER: value, + }) + ) + ).toBe(`npx @taskless/cli@${CLI_VERSION}`); + } + }); + it("is undefined when the launcher is unknown", () => { // The renderer turns this into its agent-fill marker rather than naming a // launcher the reader may not have. diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index f776ac22..b4df9eb5 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -133,13 +133,24 @@ describe("prompt rendering", () => { }); it("renders the package-manager marker by default and honors an override", () => { - const withDefault = getRecipe("ci") ?? ""; - expect(withDefault).toContain(""); - - const withOverride = - getRecipe("ci", { packageManagerDlx: "pnpm dlx" }) ?? ""; - expect(withOverride).toContain("pnpm dlx"); - expect(withOverride).not.toContain(""); + // No shipped recipe uses the placeholder since `ci` moved to the project + // launcher, but it stays part of the prompts API, so the table still + // carries it. + expect(buildVariables("", "any-topic").PACKAGE_MANAGER_DLX).toBe( + "" + ); + expect( + buildVariables("", "any-topic", { packageManagerDlx: "pnpm dlx" }) + .PACKAGE_MANAGER_DLX + ).toBe("pnpm dlx"); + }); + + it("wires CI through the project launcher with Node from the job", () => { + const ci = getRecipe("ci") ?? ""; + expect(ci).toContain("node .taskless/bin/cli.mjs check"); + expect(ci).toContain("uses: actions/setup-node@v4"); + expect(ci).not.toContain(""); + expect(ci).not.toMatch(/\b(?:pnpm dlx|yarn dlx|bunx) @taskless/); }); it("returns undefined for an unknown topic", () => { diff --git a/packages/cli/test/recipe-cross-references.test.ts b/packages/cli/test/recipe-cross-references.test.ts index dd3a3c96..f107ee44 100644 --- a/packages/cli/test/recipe-cross-references.test.ts +++ b/packages/cli/test/recipe-cross-references.test.ts @@ -124,18 +124,7 @@ function codeFragments(source: string): { line: number; text: string }[] { * is the intended outcome: a reworded exception deserves to be re-justified, * not silently carried forward. */ -const ALLOWED_HARDCODED: { file: string; snippet: string; why: string }[] = [ - { - file: "ci.md", - snippet: "npx @taskless/cli", - why: "Enumerates what %(PACKAGE_MANAGER_DLX)s expands to. Naming the four complete invocations IS the documentation; substituting the variable here would define the placeholder in terms of itself.", - }, - { - file: "ci.md", - snippet: "pnpm taskless check", - why: "That `taskless` is the binary pnpm resolves from the WIRED REPO's node_modules/.bin when @taskless/cli is their dev dependency — a foreign binary, not this CLI's invocation. %(TASKLESS_CLI)s would render `pnpm npx @taskless/cli check`.", - }, -]; +const ALLOWED_HARDCODED: { file: string; snippet: string; why: string }[] = []; function isAllowed(file: string, line: string): boolean { return ALLOWED_HARDCODED.some( From 09d9fc19a1dc6fb04d626ee4b19342a23e959259 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:10:00 -0700 Subject: [PATCH 09/15] docs(openspec): archive pinned-cli-wrapper Adds the patch changeset. Two tasks stay open as follow-ups: confirming Cursor compares a path-valued Shell() base literally, and a nightly smoke test in an auto-mode session. --- .changeset/pinned-cli-wrapper.md | 7 ++ .../.openspec.yaml | 0 .../2026-10-19-pinned-cli-wrapper}/design.md | 0 .../proposal.md | 0 .../specs/agent-global-trust/spec.md | 0 .../specs/cli-agent/spec.md | 0 .../specs/cli-init/spec.md | 0 .../specs/cli-project-launcher/spec.md | 0 .../specs/skill-taskless/spec.md | 0 .../2026-10-19-pinned-cli-wrapper}/tasks.md | 6 +- openspec/specs/agent-global-trust/spec.md | 52 +++++++++- openspec/specs/cli-agent/spec.md | 45 +++++++++ openspec/specs/cli-init/spec.md | 96 +++++++++++++++++-- openspec/specs/cli-project-launcher/spec.md | 86 +++++++++++++++++ openspec/specs/skill-taskless/spec.md | 18 ++++ 15 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 .changeset/pinned-cli-wrapper.md rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/.openspec.yaml (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/design.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/proposal.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/specs/agent-global-trust/spec.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/specs/cli-agent/spec.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/specs/cli-init/spec.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/specs/cli-project-launcher/spec.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/specs/skill-taskless/spec.md (100%) rename openspec/changes/{pinned-cli-wrapper => archive/2026-10-19-pinned-cli-wrapper}/tasks.md (96%) create mode 100644 openspec/specs/cli-project-launcher/spec.md diff --git a/.changeset/pinned-cli-wrapper.md b/.changeset/pinned-cli-wrapper.md new file mode 100644 index 00000000..f5a77b1b --- /dev/null +++ b/.changeset/pinned-cli-wrapper.md @@ -0,0 +1,7 @@ +--- +"@taskless/cli": patch +--- + +`init` now installs a project launcher, `.taskless/bin/cli.mjs` (`cli-nightly.mjs` for a nightly), that runs the CLI version recorded for it in `.taskless/taskless.json`. The installed skill and recipes fetched through the launcher name it instead of a pinned `npx` call, so the skill no longer changes on every release, and every harness's permission file gets a version-free rule for it. The `ci` recipe now generates `node .taskless/bin/cli.mjs check`, with Node supplied by the job, so a repository without a `package.json` can run Taskless in CI. + +Each package's Claude Code `autoMode.allow` entry now also covers its own launcher, so one time only, the wizard offers to replace the existing entry. More than one string under a package's label now counts as stale, and the wizard folds them into one. Settings edits are colored as migrations: `ALLOW` green, `REMOVE` uncolored, `REPLACE` yellow, and `SKIPPED` red, which marks an edit you have to make yourself. A missing or stale global entry after `init` prints as a red `SKIPPED (global):` line. diff --git a/openspec/changes/pinned-cli-wrapper/.openspec.yaml b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/.openspec.yaml similarity index 100% rename from openspec/changes/pinned-cli-wrapper/.openspec.yaml rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/.openspec.yaml diff --git a/openspec/changes/pinned-cli-wrapper/design.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/design.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md diff --git a/openspec/changes/pinned-cli-wrapper/proposal.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/proposal.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/proposal.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/proposal.md diff --git a/openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/agent-global-trust/spec.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/specs/agent-global-trust/spec.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/agent-global-trust/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-agent/spec.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/specs/cli-agent/spec.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-agent/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/specs/cli-init/spec.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-init/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-project-launcher/spec.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/specs/cli-project-launcher/spec.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/cli-project-launcher/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/skill-taskless/spec.md similarity index 100% rename from openspec/changes/pinned-cli-wrapper/specs/skill-taskless/spec.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/specs/skill-taskless/spec.md diff --git a/openspec/changes/pinned-cli-wrapper/tasks.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md similarity index 96% rename from openspec/changes/pinned-cli-wrapper/tasks.md rename to openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md index 0d3a4d49..50467104 100644 --- a/openspec/changes/pinned-cli-wrapper/tasks.md +++ b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md @@ -45,6 +45,6 @@ - [x] 7.1 Confirm `./.taskless/bin/cli.mjs` runs from Claude Code's Git Bash on Windows (confirmed 2026-10-19) - [ ] 7.2 Run the nightly's launcher once in an auto-mode session with the new global entry, as a smoke test -- [ ] 7.3 Add a `patch` changeset describing the launcher and the one-time global entry replacement -- [ ] 7.4 Run `pnpm typecheck` and `pnpm lint` -- [ ] 7.5 Archive the change on the PR +- [x] 7.3 Add a `patch` changeset describing the launcher and the one-time global entry replacement +- [x] 7.4 Run `pnpm typecheck` and `pnpm lint` +- [x] 7.5 Archive the change on the PR diff --git a/openspec/specs/agent-global-trust/spec.md b/openspec/specs/agent-global-trust/spec.md index 59fbee52..c323e43a 100644 --- a/openspec/specs/agent-global-trust/spec.md +++ b/openspec/specs/agent-global-trust/spec.md @@ -28,7 +28,7 @@ A harness SHALL be considered only when `init` installs the skill for it, by the ### Requirement: The global entry names no version and requires one -Each package SHALL have its own entry: the release's begins with the label `Taskless CLI:` and names only `@taskless/cli`; the nightly's begins with the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. Neither label SHALL be a prefix of the other's entry. The released build SHALL use the release's entry, and every other build (nightly, `self`) the nightly's. Each entry SHALL state that running its package through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. +Each package SHALL have its own entry: the release's begins with the label `Taskless CLI:` and names only `@taskless/cli`; the nightly's begins with the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. Neither label SHALL be a prefix of the other's entry. The released build SHALL use the release's entry, and every other build (nightly, `self`) the nightly's. Each entry SHALL state that running its package through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. Each entry SHALL also state that running its package's launcher from the project root (`./.taskless/bin/cli.mjs` for the release, `./.taskless/bin/cli-nightly.mjs` for the nightly) is allowed with any arguments, and that the launcher runs the exact version the project recorded for that package. It SHALL NOT name the other package's launcher. It SHALL NOT name a version, so an upgrade never makes it stale. Changing the entry to cover the launcher makes every entry written before it stale, which the wizard replaces as it replaces any stale entry. Each entry SHALL also state that the call stays covered when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file, and that every other command on that line is judged on its own. It SHALL NOT extend coverage to any command other than the pinned Taskless call. @@ -55,11 +55,22 @@ Each entry SHALL also state that the call stays covered when the same command li - **THEN** it SHALL say the call stays covered alongside a variable assignment, a directory change or creation, or a redirect of its output to a file - **AND** it SHALL say every other command on the line is judged on its own +#### Scenario: The entry covers its own launcher + +- **WHEN** the release's entry is read +- **THEN** it SHALL say running `./.taskless/bin/cli.mjs` from the project root is allowed +- **AND** it SHALL NOT name `cli-nightly.mjs` + +#### Scenario: An entry from before the launcher is stale + +- **WHEN** `autoMode.allow` holds the release's entry as written before this change +- **THEN** `init` SHALL report it as stale + ### Requirement: The global entry is detected by its label -`init` SHALL find the entry by this build's label: a string in the harness's list beginning with it is this build's entry, whatever follows. A string beginning with the other package's label SHALL never be read, edited, or removed, so a release and a nightly used on one machine never replace each other's entry. An entry whose whole text equals the current entry SHALL be `present` and SHALL never be rewritten. An entry that carries the label with any other text SHALL be `stale`. A string without this build's label SHALL never be read as this build's entry, edited, or removed. +`init` SHALL find the entry by this build's label: a string in the harness's list beginning with it is this build's entry, whatever follows. A string beginning with the other package's label SHALL never be read, edited, or removed, so a release and a nightly used on one machine never replace each other's entry. The entry SHALL be `present` when exactly one string carries this build's label and its whole text equals the current entry, and a present entry SHALL never be rewritten. It SHALL be `stale` when any string carrying the label has other text, or when more than one string carries the label, even if one of them is current. A string without this build's label SHALL never be read as this build's entry, edited, or removed. -A stale entry SHALL be replaced only in place, by the current entry, and only where the wizard writes it. When the list holds more than one string carrying the label, the first SHALL be replaced and the rest left as they are. +A stale entry SHALL be replaced only where the wizard writes it, and the replacement SHALL leave exactly one string carrying the label: the first such string SHALL be replaced in place by the current entry, and every later string carrying the label SHALL be removed. Strings carrying the other package's label, and strings with no Taskless label, SHALL keep their text and their order. #### Scenario: A current entry is left alone @@ -85,6 +96,18 @@ A stale entry SHALL be replaced only in place, by the current entry, and only wh - **THEN** `init` SHALL report the release's entry as absent, not stale - **AND** writing it SHALL give `["$defaults", , ]` +#### Scenario: Duplicate entries fold into one + +- **WHEN** the wizard installs for `.claude/` and `autoMode.allow` is `["$defaults", , "Internal Registry: ...", "Taskless CLI: pasted by hand", ]` +- **THEN** `init` SHALL report the entry as stale +- **AND** on confirmation the list SHALL be `["$defaults", , "Internal Registry: ...", ]` + +#### Scenario: A current entry with a duplicate is not present + +- **WHEN** `autoMode.allow` holds the current entry twice +- **THEN** `init` SHALL report the entry as stale +- **AND** the wizard SHALL leave one copy, at the first one's position + ### Requirement: The global entry is offered only in the interactive wizard When the entry is absent or stale, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. @@ -146,7 +169,7 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline ### Requirement: A missing global entry is warned about -When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. For a stale entry the warning SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. +When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a `SKIPPED (global):` line in red, in both modes, as the reporting of permission edits colors a skip, followed by text naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. A skip is an edit the user has to make by hand, which is why it is red; `init` SHALL NOT make it for them. For a stale entry the text SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` this output SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. #### Scenario: Non-interactive warns @@ -178,3 +201,24 @@ When a harness in the table is installed for and its entry is absent or stale af - **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is stale - **THEN** the envelope SHALL report `~/.claude/settings.json` with state `stale` + +#### Scenario: The skip is red + +- **WHEN** color is on and `init --no-interactive` runs for `.claude/` with the entry absent +- **THEN** the output SHALL include a red `SKIPPED (global):` line naming `~/.claude/settings.json` + +### Requirement: Cleanup of the global entry runs on every install and upgrade + +The check for this build's global entry, including folding duplicates, SHALL run on every `init`, whether it is a first install or an upgrade, and SHALL NOT be a scaffold migration: migrations run on every write path, including `--no-interactive` and CI, and the entry lives outside the project. The wizard SHALL perform the whole cleanup in the one edit its summary step confirms, shown as a single `REPLACE (global):` line, never as one line per removed string. When the entry is stale because of duplicates, the `SKIPPED (global):` output that `init` prints otherwise SHALL say how many strings carry the label and that the wizard will fold them into one. + +#### Scenario: Non-interactive names the duplicates + +- **WHEN** `init --no-interactive` runs for `.claude/` and `autoMode.allow` holds two strings carrying this build's label +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** the `SKIPPED (global):` output SHALL say two strings carry the label and that running the installer with no arguments in a terminal folds them into one + +#### Scenario: One confirmation covers the fold + +- **WHEN** the wizard finds three strings carrying this build's label +- **THEN** the summary SHALL show one `REPLACE (global):` line for them +- **AND** declining SHALL leave the file byte-identical diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index f6d2510b..b3ff9d67 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -459,3 +459,48 @@ When rendered with `mechanics: false`, the paragraph SHALL name no invocation an - **WHEN** the `check` recipe describes scanning the files a branch changed - **THEN** it SHALL tell the agent to run `git diff --name-only` as its own call and pass the paths it printed - **AND** SHALL NOT show `$(git diff ...)` inside a Taskless command line + +### Requirement: Recipes fetched through a launcher name the launcher + +When the CLI was started by a project launcher, as the launcher's environment variable reports, every `%(TASKLESS_CLI)s` in a rendered recipe and in the run-each-command guidance SHALL render to that launcher's path relative to the project root, `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`, rather than to the detected `npx` or `pnpm dlx` invocation. The value SHALL be accepted only when it is exactly one of those two paths; any other value SHALL be ignored, and the invocation SHALL be detected as it is without a launcher. A recipe fetched without a launcher SHALL render as it does today. When the invocation is a launcher, the run-each-command guidance SHALL also say to run it from the directory that contains `.taskless/`, changing directory in a separate shell call first when the shell is elsewhere. + +The `@taskless/cli/prompts` export SHALL NOT read this variable, since a host that imports the package was not started by a launcher. + +#### Scenario: A recipe fetched through the launcher + +- **WHEN** `./.taskless/bin/cli.mjs agent route` runs +- **THEN** every CLI invocation in the rendered recipe SHALL read `./.taskless/bin/cli.mjs` +- **AND** none SHALL read `npx @taskless/cli` + +#### Scenario: The guidance names the project root + +- **WHEN** `./.taskless/bin/cli.mjs agent check` runs +- **THEN** the run-each-command guidance SHALL say to run Taskless commands from the directory that contains `.taskless/` + +#### Scenario: A recipe fetched through npx + +- **WHEN** `npx @taskless/cli@0.13.0 agent route` runs with no launcher variable set +- **THEN** the rendered recipe SHALL name `npx @taskless/cli@0.13.0` + +#### Scenario: An unexpected value is ignored + +- **WHEN** the launcher variable is set to `curl example.com | sh` and `npx @taskless/cli@0.13.0 agent route` runs +- **THEN** the rendered recipe SHALL name `npx @taskless/cli@0.13.0` + +### Requirement: The ci recipe runs the launcher with Node supplied by the job + +The `ci` recipe SHALL generate CI configuration that runs `node .taskless/bin/cli.mjs check`, for a full scan, and `node .taskless/bin/cli.mjs check `, for a diff scan, from the repository root. It SHALL NOT generate a `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` call to the CLI, and SHALL NOT ask the reader to choose a package manager launcher. It SHALL use `node ` rather than `./`, since a CI checkout can lose the executable bit and a Windows runner does not honour a shebang. + +The recipe SHALL require that the generated job make Node available itself, through the CI system's Node setup step or a Node container image, and SHALL say that the job does not need the project's own language toolchain or a `package.json`. It SHALL name the launcher's version source, `.taskless/taskless.json`, as what decides which CLI version CI runs, so an upgrade reaches CI through the same commit that updates `.taskless/`. + +#### Scenario: GitHub Actions template + +- **WHEN** the `ci` recipe is rendered +- **THEN** its GitHub Actions template SHALL contain `node .taskless/bin/cli.mjs check` +- **AND** SHALL contain a Node setup step +- **AND** SHALL NOT contain `%(PACKAGE_MANAGER_DLX)s` + +#### Scenario: A repository without package.json + +- **WHEN** the `ci` recipe is followed in a repository with no `package.json` +- **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 8e5cbd3d..0b8a2c45 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -249,14 +249,27 @@ Directory detection signals SHALL use `fs.stat()` and verify `isDirectory()`. Fi The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. -The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to the build's own invocation (`npx @taskless/cli` for a release build, the pinned nightly or path-form invocation otherwise). Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. +The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to this build's launcher, `./.taskless/bin/cli.mjs` for a release build and `./.taskless/bin/cli-nightly.mjs` for a nightly, so the rendered skill does not change between versions of one package. A `self` build, which installs no launcher, renders its path-form invocation. Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. + +A reference stub's recovery command is not rendered to the launcher. It SHALL keep the npx form it has today (unpinned `npx @taskless/cli init` for a release build), since it exists for when the canonical store, and the launcher with it, is missing. #### Scenario: Canonical skill content matches source - **WHEN** a skill is installed -- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` with every `%(TASKLESS_CLI)s` rendered to the build's invocation, which for a release build is `npx @taskless/cli` +- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` with every `%(TASKLESS_CLI)s` rendered to the build's launcher, which for a release build is `./.taskless/bin/cli.mjs` - **AND** no frontmatter fields SHALL be modified at install time +#### Scenario: The rendered skill does not move with the version + +- **WHEN** the release's `init` runs at `0.13.0` and then at `0.13.1`, and the skill source did not change between them +- **THEN** the canonical `SKILL.md` SHALL be byte-identical after both + +#### Scenario: A stub's recovery command keeps the npx form + +- **WHEN** the release's `init` at `0.13.0` writes a reference stub +- **THEN** the stub's recovery command SHALL be `npx @taskless/cli init` +- **AND** SHALL NOT name `./.taskless/bin/cli.mjs` + #### Scenario: The sources carry the placeholder, not the literal - **WHEN** the skill source under `skills/` is read @@ -980,13 +993,15 @@ Edited .claude/settings.json REMOVE: Bash(npx @taskless/cli@0.13.0 *) ``` -- `ALLOW: ` for a rule added, rendered in green. -- `REMOVE: ` for a Taskless rule taken out because it is not the installed invocation's rule, whether or not the manifest recorded it, rendered in red. -- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in yellow. +- `ALLOW: ` for a rule added, rendered in green, since an addition is what the reader needs to notice. +- `REMOVE: ` for a Taskless rule taken out because it is not one of the installed rules, whether or not the manifest recorded it, rendered in the terminal's default color, since removing Taskless's own outdated rules is expected. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in red. A skipped edit is one the user has to make by hand, and the Taskless commands it would have allowed can be refused until they do, so it is the only label rendered in red. + +The same color for each label SHALL apply wherever a settings edit is shown, project or global, in the wizard's summary and in printed output. `REPLACE`, which only the global trust entry uses, SHALL be rendered in yellow: a change to an existing entry, worth a moment's attention. The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. -The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, colored as above, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. #### Scenario: A first install names the edit @@ -1020,6 +1035,11 @@ The wizard SHALL show each permission edit in its summary step, before anything - **WHEN** `init --json` edits a permission file - **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed +#### Scenario: Each label has its color + +- **WHEN** color is on and `init` reports an added rule, a removed rule, a replaced global entry, and a skipped file +- **THEN** the `ALLOW` line SHALL be green, the `REMOVE` line uncolored, the `REPLACE` line yellow, and the `SKIPPED` line red + ### Requirement: Init never overrides a deny or a file it cannot read `init` SHALL NOT write a rule to a harness file that denies the invocation, or that holds a rule which would take precedence over the Taskless rule and refuse it. It SHALL NOT write to a file that does not parse. In each case it SHALL leave the file unchanged and name it in the summary with the reason. @@ -1055,7 +1075,7 @@ Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file e ### Requirement: Each permission file holds exactly one Taskless rule -Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, and it belongs to the package its command names. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed. The record SHALL keep the other package's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. @@ -1103,3 +1123,65 @@ The manifest SHALL record, under `install.permissions`, each file Taskless wrote - **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the release's `init` runs with `.claude/` no longer selected - **THEN** the file SHALL remain, holding the nightly's rule - **AND** once the nightly's `init` runs the same way, the file SHALL be deleted + +#### Scenario: Removing a harness removes its launcher rule + +- **WHEN** the manifest records `Bash(./.taskless/bin/cli.mjs *)` in `.claude/settings.json` and the release's `init` runs with `.claude/` no longer selected +- **THEN** that rule SHALL be removed with the release's other Taskless rules +- **AND** `Bash(./.taskless/bin/cli-nightly.mjs *)` SHALL remain + +### Requirement: Init installs the package's launcher and records its version + +Every `init` that writes the canonical skill, in the wizard and in `--no-interactive`, SHALL also write this build's launcher, as defined by the `cli-project-launcher` capability, and record this build's version for its package under `install.launchers` in `.taskless/taskless.json`, keyed by package name. It SHALL keep the other package's recorded version. `install.cliVersion` SHALL keep recording the build that last installed. + +A launcher whose bytes differ from this build's launcher SHALL be rewritten, and the install summary SHALL name it as restored, since a launcher runs whatever it contains under any rule that allows it. A `self` build SHALL write no launcher and record no version under `install.launchers`. + +No scaffold migration SHALL be added for this: the launcher and its record are install output, written by the same step as the canonical skill. + +#### Scenario: A release install records its version + +- **WHEN** `init` runs from the release at `0.13.0` in a project with no `install.launchers` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0" }` +- **AND** `install.cliVersion` SHALL be `0.13.0` + +#### Scenario: A nightly install keeps the release's version + +- **WHEN** `install.launchers` is `{ "@taskless/cli": "0.13.0" }` and a nightly `init` runs at `0.14.0-20261020120000xabc1234` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` +- **AND** `install.cliVersion` SHALL be `0.14.0-20261020120000xabc1234` + +#### Scenario: An edited launcher is restored and reported + +- **WHEN** `.taskless/bin/cli.mjs` was edited and the release's `init` runs +- **THEN** the file SHALL hold this build's launcher afterwards +- **AND** the summary SHALL name `.taskless/bin/cli.mjs` as restored + +### Requirement: Init allows the installed launcher in each harness's permission file + +Alongside the rules for the installed invocation, `init` SHALL write a rule allowing this build's launcher, spelled `./.taskless/bin/` with no version, to the same permission file of each harness it writes a rule for, in that harness's word-bounded form: + +| Harness | Launcher rule | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `permissions.allow`: `Bash(./.taskless/bin/ *)` | +| Cursor (editor) | `terminalAllowlist`: `./.taskless/bin/` | +| Cursor (CLI) | `permissions.allow`: `Shell(./.taskless/bin/)` | +| OpenCode | object form: `permission.bash["./.taskless/bin/ *"]: "allow"`; array form: `{ "action": "shell", "resource": "./.taskless/bin/ *", "effect": "allow" }` | +| Codex | `prefix_rule(pattern=["./.taskless/bin/"], decision="allow")` | + +A harness whose rule format cannot match a command given as a relative path SHALL receive no launcher rule, and that SHALL be recorded in this table rather than approximated. The launcher rule SHALL be reported, recorded, and placed exactly as the invocation's rules are, by the requirements that init reports every permission edit and that each permission file holds exactly one Taskless rule set per package. A `self` build SHALL write no launcher rule. + +#### Scenario: Claude Code gets the launcher rule + +- **WHEN** the release's `init` installs for `.claude/` at `0.13.0` +- **THEN** `.claude/settings.json` SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` and `Bash(./.taskless/bin/cli.mjs *)` in `permissions.allow` + +#### Scenario: The launcher rule survives an upgrade + +- **WHEN** `.claude/settings.json` holds the release's rules for `0.13.0` and the release's `init` runs at `0.13.1` +- **THEN** `Bash(./.taskless/bin/cli.mjs *)` SHALL remain, and SHALL NOT be reported as an edit + +#### Scenario: The nightly's launcher rule names its own launcher + +- **WHEN** a nightly `init` installs for `.claude/` +- **THEN** `.claude/settings.json` SHALL contain `Bash(./.taskless/bin/cli-nightly.mjs *)` +- **AND** SHALL NOT gain `Bash(./.taskless/bin/cli.mjs *)` diff --git a/openspec/specs/cli-project-launcher/spec.md b/openspec/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..6480e233 --- /dev/null +++ b/openspec/specs/cli-project-launcher/spec.md @@ -0,0 +1,86 @@ +# cli-project-launcher Specification + +## Purpose + +A project-local launcher that runs the Taskless CLI version the project recorded, so agents, git hooks, and CI in any language call one fixed command that an agent harness can allow by path. + +## Requirements + +### Requirement: Each package has its own launcher + +The release package SHALL install its launcher at `.taskless/bin/cli.mjs`, and the nightly package at `.taskless/bin/cli-nightly.mjs`. A build SHALL write, rewrite, or remove only its own package's launcher, and SHALL NEVER read, edit, or remove the other package's. A build whose invocation names neither package, such as a `self` build, SHALL write no launcher. + +Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env node`, and SHALL be written with its executable bits set where the filesystem supports them. Its content SHALL be identical for every version of its package, so an upgrade never changes its bytes unless the launcher itself changed. + +#### Scenario: The release installs cli.mjs + +- **WHEN** `init` runs from the release package +- **THEN** `.taskless/bin/cli.mjs` SHALL exist, begin with `#!/usr/bin/env node`, and be executable +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL NOT be created + +#### Scenario: A nightly leaves the release's launcher alone + +- **WHEN** `.taskless/bin/cli.mjs` exists and `init` runs from a nightly +- **THEN** `.taskless/bin/cli.mjs` SHALL be byte-identical afterwards +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL exist + +#### Scenario: An upgrade does not rewrite an unchanged launcher + +- **WHEN** `init` runs at `0.13.1` over a project installed at `0.13.0`, and the launcher template did not change between them +- **THEN** the launcher's bytes SHALL be unchanged + +### Requirement: A launcher runs the version recorded for its package + +When run, a launcher SHALL read `.taskless/taskless.json` from the `.taskless/` directory that contains it, not from the working directory, and take its package's version from `install.launchers`. When that field has no version for its package, it SHALL fall back to `install.cliVersion`, but only when that version belongs to its package: a version containing `-` belongs to the nightly, and one without belongs to the release. It SHALL then run `npx --yes @` with every argument it was given, unchanged and in order, inheriting standard input, output, and error, and the working directory and environment it was started with. + +The launcher SHALL add one variable to the environment it passes on, naming its own path relative to the project root, so the CLI it starts can name the launcher in what it prints. + +It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the launcher SHALL exit as that signal would end it. It SHALL make no network request of its own and SHALL NEVER fall back to an unpinned or dist-tagged version. + +#### Scenario: The release launcher runs its pin + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0` and `./.taskless/bin/cli.mjs check src/a.ts` runs +- **THEN** it SHALL run `npx --yes @taskless/cli@0.13.0 check src/a.ts` + +#### Scenario: A nightly install does not move the release launcher + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0`, and a nightly `init` sets `install.cliVersion` to `0.14.0-20261020120000xabc1234` +- **THEN** `./.taskless/bin/cli.mjs` SHALL still run `@taskless/cli@0.13.0` + +#### Scenario: A manifest from an older CLI still works + +- **WHEN** `install.launchers` is absent and `install.cliVersion` is `0.13.0` +- **THEN** `./.taskless/bin/cli.mjs` SHALL run `@taskless/cli@0.13.0` +- **AND** `./.taskless/bin/cli-nightly.mjs` SHALL NOT run `0.13.0` + +#### Scenario: Run from a subdirectory + +- **WHEN** the working directory is `src/` and `../.taskless/bin/cli.mjs check` runs +- **THEN** the launcher SHALL read the version from the `.taskless/` that contains it +- **AND** the CLI SHALL start in `src/` + +#### Scenario: The exit status passes through + +- **WHEN** the CLI exits with status 1 because a rule failed +- **THEN** the launcher SHALL exit with status 1 + +### Requirement: A launcher without a version fails plainly + +When no version for its package can be read, because `.taskless/taskless.json` is missing or does not parse, or neither field holds a version for its package, the launcher SHALL run nothing, print one message to standard error naming the file it read and the installer command that records a version, and exit with a non-zero status. + +#### Scenario: No version recorded + +- **WHEN** `install.launchers` and `install.cliVersion` hold no release version and `./.taskless/bin/cli.mjs check` runs +- **THEN** no `npx` process SHALL start +- **AND** standard error SHALL name `.taskless/taskless.json` and the installer command +- **AND** the exit status SHALL be non-zero + +### Requirement: A launcher describes itself plainly + +A launcher's comments SHALL state only what it does: that it runs the Taskless CLI version recorded in `.taskless/taskless.json` for its package. They SHALL NOT describe the launcher as a way around a permission prompt, a safety classifier, or a review, since an agent that reads such a comment can be refused for it. + +#### Scenario: The comments say what it does + +- **WHEN** a launcher's source is read +- **THEN** its comments SHALL name `.taskless/taskless.json` and the package it runs +- **AND** SHALL NOT contain the words "bypass", "classifier", or "auto mode" diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 900e8c98..79109208 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -237,3 +237,21 @@ The skill body SHALL instruct the agent to run every Taskless command as a comma - **WHEN** the skill body is read from source - **THEN** it SHALL instruct the agent to write a file a recipe asks for with its file tools, not by redirecting a Taskless command's output + +### Requirement: Skill runs the launcher from the project root + +The installed skill SHALL tell the agent to run every Taskless command from the project root, the directory that contains `.taskless/`, since the launcher's path is relative to it and a permission rule matches the command text, not the directory it runs in. When the agent's shell is elsewhere, the skill SHALL tell it to change into the project root in a separate shell call first, and then run the Taskless command on its own line, so the rule that a Taskless line carries no `cd` still holds. When more than one `.taskless/` directory exists above or around the working files, the skill SHALL name the nearest one containing the files the user is asking about as the project root. + +The skill SHALL say that a "No such file or directory" error from the launcher means the shell is not at the project root, and that the fix is to change directory and rerun, not to call `npx` or `node` directly. + +#### Scenario: The skill names the project root + +- **WHEN** the installed skill is read +- **THEN** it SHALL say Taskless commands run from the directory that contains `.taskless/` +- **AND** it SHALL say to change directory in a separate shell call, never on the Taskless line + +#### Scenario: A missing launcher is a directory problem + +- **WHEN** the installed skill is read +- **THEN** it SHALL say a "No such file or directory" error from the launcher means the shell is not at the project root +- **AND** it SHALL NOT suggest running the CLI through `npx` or `node` instead From 8946a16201e32579dd5af2c8808e4c6ddff6530b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:19:26 -0700 Subject: [PATCH 10/15] feat: name the launcher in commands printed for a person When the project launcher started the CLI, remedies and hints name ./.taskless/bin/cli.mjs instead of npx; any other TASKLESS_LAUNCHER value is ignored. The pending-migration and unreadable-manifest remedies now use the same choice, and .taskless/README.md lists the package's launcher first. --- .taskless/README.md | 3 +++ packages/cli/src/filesystem/manifest.ts | 4 ++-- packages/cli/src/filesystem/migrate.ts | 4 ++-- .../cli/src/filesystem/migrations/0001-init.ts | 12 ++++++++++++ packages/cli/src/util/package-manager.ts | 10 ++++++++-- packages/cli/test/package-manager.test.ts | 18 +++++++++++++++++- packages/cli/test/scaffold-readme.test.ts | 11 +++++++++++ 7 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.taskless/README.md b/.taskless/README.md index e7d6fd5d..fd229cab 100644 --- a/.taskless/README.md +++ b/.taskless/README.md @@ -7,6 +7,9 @@ This directory contains [Taskless](https://taskless.io) configuration and rules Run the Taskless scanner from your repository root: ```sh +# the project launcher, from the repository root +./.taskless/bin/cli.mjs check + # npm / pnpm pnpm dlx @taskless/cli@latest check diff --git a/packages/cli/src/filesystem/manifest.ts b/packages/cli/src/filesystem/manifest.ts index cc888cad..0ae3515f 100644 --- a/packages/cli/src/filesystem/manifest.ts +++ b/packages/cli/src/filesystem/manifest.ts @@ -2,7 +2,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { CLIError } from "../util/cli-error"; -import { buildInvocation } from "../util/invocation"; +import { getCliPrefix } from "../util/package-manager"; import { isRecord } from "../util/is-record"; /** @@ -112,7 +112,7 @@ function unreadableManifest(path: string, reason: string): CLIError { return new CLIError( `${path} could not be read: ${reason}.\n\n` + `This is not a schema version mismatch, so migrating will not help: ` + - `\`${buildInvocation()} init\` refuses here too, rather than rewriting the file ` + + `\`${getCliPrefix()} init\` refuses here too, rather than rewriting the file ` + `with only the part it can parse. A leftover merge conflict, a truncated write ` + `or a partial editor save are the usual causes.\n\n` + `Repair the JSON by hand, or delete the file to rebuild the scaffold from scratch.`, diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index 2e9d57ce..3d43ef79 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import process from "node:process"; import { CLIError } from "../util/cli-error"; -import { buildInvocation } from "../util/invocation"; +import { getCliPrefix } from "../util/package-manager"; import { pathExists } from "../rules/reconcile-marker"; import { readRawManifest, writeRawManifest } from "./manifest"; import type { Migrations } from "./types"; @@ -240,7 +240,7 @@ export async function requireCurrentSchema(cwd: string): Promise { `This project's .taskless/ is at schema version ${String(pending.from)}, and this ` + `CLI expects ${String(pending.to)}. Migrating moves and deletes files, so it is ` + `not done as a side effect of a command that only reads.\n\n` + - `Run \`${buildInvocation()} init\` to migrate, then run this again.`, + `Run \`${getCliPrefix()} init\` to migrate, then run this again.`, "SCAFFOLD_MIGRATION_REQUIRED" ); } diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index 8afa373a..d70cb576 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -10,6 +10,7 @@ import { import { addToGitignore } from "../gitignore"; import type { Migration } from "../types"; +import { LAUNCHER_PACKAGES, launcherInvocation } from "../../install/launcher"; import { buildInvocation } from "../../util/invocation"; import { readmeSpecifier } from "../../util/package-manager"; @@ -43,7 +44,18 @@ function usageBlock(specifier: string | undefined): string { if (specifier === undefined) { 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. + const [release, nightly] = LAUNCHER_PACKAGES; + const launcher = launcherInvocation( + specifier.startsWith(`${nightly}@`) ? nightly : release + ); return [ + "# the project launcher, from the repository root", + `${launcher} check`, + "", "# npm / pnpm", `pnpm dlx ${specifier} check`, "", diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 697f771e..8e0b3543 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -225,12 +225,18 @@ function projectLauncher(context: LauncherContext): string | undefined { * staring at an error needs something runnable, and `npx` resolves on every * machine. That display default is why the two functions are separate; a * marker would be useless here and a guessed launcher is useless in a recipe. + * + * When a project launcher started this process, it is what the person just + * ran, so it is what they are told to run next. */ export function getCliPrefix(): string { + const context = processLauncherContext(); + const launcher = projectLauncher(context); + if (launcher !== undefined) return launcher; const specifier = pinnedSpecifier(); // A path-form build has no launcher to choose, so there is nothing to // default to — the same reason `detectCliInvocation` returns early on it. if (specifier === undefined) return buildInvocation(); - const launcher = detectLauncher(processLauncherContext()); - return `${LAUNCHER_COMMANDS[launcher ?? "npx"]} ${specifier}`; + const detected = detectLauncher(context); + return `${LAUNCHER_COMMANDS[detected ?? "npx"]} ${specifier}`; } diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index 0fb33836..968e7f3c 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { detectCliInvocation, @@ -214,6 +214,22 @@ describe("detectCliInvocation", () => { }); describe("getCliPrefix", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("names the project launcher that started this process", () => { + vi.stubEnv("TASKLESS_LAUNCHER", "./.taskless/bin/cli.mjs"); + expect(getCliPrefix()).toBe("./.taskless/bin/cli.mjs"); + }); + + it("ignores a launcher variable that is not one of the two paths", () => { + vi.stubEnv("TASKLESS_LAUNCHER", "./evil.sh"); + expect(getCliPrefix()).toMatch( + new RegExp(`^(?:npx|pnpm dlx) @taskless/cli@${CLI_VERSION}$`) + ); + }); + it("prints npx when detection is unknown, because a human needs something runnable", () => { // Whatever launcher the test runner used, the printed command must resolve. expect(getCliPrefix()).toMatch( diff --git a/packages/cli/test/scaffold-readme.test.ts b/packages/cli/test/scaffold-readme.test.ts index 4dbc8c57..0d92c2fc 100644 --- a/packages/cli/test/scaffold-readme.test.ts +++ b/packages/cli/test/scaffold-readme.test.ts @@ -42,6 +42,17 @@ describe("buildReadmeContent", () => { expect(readme).not.toContain("@taskless/cli@"); }); + it("lists each package's own launcher first", () => { + const released = buildReadmeContent(RELEASED_SPECIFIER); + expect(released).toContain("./.taskless/bin/cli.mjs check"); + expect(released.indexOf("./.taskless/bin/cli.mjs")).toBeLessThan( + released.indexOf("pnpm dlx") + ); + const nightly = buildReadmeContent(NIGHTLY_SPECIFIER); + expect(nightly).toContain("./.taskless/bin/cli-nightly.mjs check"); + expect(nightly).not.toContain("./.taskless/bin/cli.mjs"); + }); + it("names its one invocation for a path-form build", () => { // A `self` build's invocation is `node `, which no launcher fronts. // `pnpm dlx node packages/cli/dist-self/index.js` is not a command, so the From cd039721628be3dc648340af95d896262f708b54 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:19:29 -0700 Subject: [PATCH 11/15] feat(agent): run pre-commit hooks through the project launcher The hooks recipe now runs node .taskless/bin/cli.mjs in every hook tool, and no longer adds @taskless/cli as a dev dependency, which was a second pin to keep in step with .taskless/taskless.json. --- packages/cli/src/agent/hooks.md | 70 +++++++++++++++---------------- packages/cli/test/prompts.test.ts | 7 ++++ 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/agent/hooks.md b/packages/cli/src/agent/hooks.md index c73a890f..2171cc49 100644 --- a/packages/cli/src/agent/hooks.md +++ b/packages/cli/src/agent/hooks.md @@ -1,4 +1,4 @@ -# Topic: hooks (CLI v%(CLI_VERSION)s / topic v3) +# Topic: hooks (CLI v%(CLI_VERSION)s / topic v4) %(ONE_COMMAND_PER_CALL)s @@ -18,6 +18,10 @@ deciding when rules run. `%(TASKLESS_CLI)s agent ci` is the other half. very next commit. - The user has agreed to a hook. A hook runs on every developer's commits, so it is a team decision, not a setup detail. +- `.taskless/bin/cli.mjs` exists and is committed. It is the project + launcher `init` writes; the hook runs it. If it is missing, the + project was installed by an older CLI: tell the user to run + `%(TASKLESS_CLI)s init` and commit the result first. ## Steps @@ -45,31 +49,24 @@ not install one on your own initiative. A script placed directly in `.git/hooks/` is not committed, so it runs only on the machine that wrote it. Say so if the user asks for one. -### 2. Pin the CLI as a dev dependency - -The hook runs the version the repository pins, so the hook, CI and -every developer agree on what `check` means. A download-and-run -launcher fetches whatever is newest at commit time instead. - -If `@taskless/cli` is not already in `devDependencies`, offer to add -it with the repository's package manager (read the lockfile: -`pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, or `package-lock.json`). - -The examples below write `` for that pinned binary. -Substitute the form that resolves it from the repository's own -`node_modules/.bin`: - -- pnpm: `pnpm exec taskless` -- npm: `npx --no taskless` -- yarn: `yarn taskless` -- bun: `bun run taskless` - -The npm form keeps `--no` on purpose: without it, npx downloads an -unrelated package named `taskless` when the dev dependency is missing, -rather than failing. - -Inside a lint-staged task a bare `taskless` also resolves, because -lint-staged puts `node_modules/.bin` on the `PATH` for its tasks. +### 2. Run the project launcher + +The hook runs `node .taskless/bin/cli.mjs`, the project launcher. It +runs the CLI version recorded in `.taskless/taskless.json`, so the +hook, CI, and every developer run the same version, and an upgrade +reaches the hook in the same commit that updates `.taskless/`. Do not +add `@taskless/cli` as a dev dependency for the hook, and do not call +`npx`, `pnpm dlx`, `yarn dlx`, or `bunx` from it: a second pin is a +second version to keep in step. + +`node ` rather than `./`: a checkout can lose the +launcher's executable bit, and `node` runs it the same way on every +platform. Git runs hooks from the repository root, which is where the +path resolves. If `.taskless/` is not at the repository root (a +package inside a monorepo), run the command from that package's +directory, using the hook tool's own option for it, or ask the user how +their hooks reach that package. The first run downloads the recorded +version, so the first commit after an upgrade is slower than the rest. ### 3. Write the pre-commit command @@ -83,7 +80,7 @@ glob to the command: ```json { - "*": " check" + "*": "node .taskless/bin/cli.mjs check" } ``` @@ -94,7 +91,7 @@ lefthook skips the command when it is empty: pre-commit: commands: taskless: - run: check {staged_files} + run: node .taskless/bin/cli.mjs check {staged_files} ``` **pre-commit.** pre-commit passes the staged filenames to `entry`: @@ -105,7 +102,7 @@ repos: hooks: - id: taskless name: Taskless - entry: check + entry: node .taskless/bin/cli.mjs check language: system pass_filenames: true ``` @@ -116,7 +113,7 @@ with git: ```sh FILES=$(git diff --cached --name-only --diff-filter=ACMR) [ -z "$FILES" ] && exit 0 - check $FILES +node .taskless/bin/cli.mjs check $FILES ``` `--diff-filter=ACMR` drops deleted files up front. `check` would skip @@ -150,8 +147,8 @@ and it can affect files that were not staged. In that case run the rule tests and a full, unscoped `check`: ``` - verify - check +node .taskless/bin/cli.mjs verify +node .taskless/bin/cli.mjs check ``` lint-staged can express that in a JavaScript config, where a function @@ -160,10 +157,10 @@ written, with no files appended: ```js export default { - "*": " check", + "*": "node .taskless/bin/cli.mjs check", ".taskless/rules/**": () => [ - " verify", - " check", + "node .taskless/bin/cli.mjs verify", + "node .taskless/bin/cli.mjs check", ], }; ``` @@ -203,8 +200,7 @@ is staged. ### 7. Report back -Show the file you changed and the lines you added, any dev dependency -you added, and `git status`. Do not commit on the user's behalf. +Show the file you changed and the lines you added, and `git status`. Do not commit on the user's behalf. ## Errors diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index b4df9eb5..19259d29 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -145,6 +145,13 @@ describe("prompt rendering", () => { ).toBe("pnpm dlx"); }); + it("runs the pre-commit hook through the project launcher", () => { + const hooks = getRecipe("hooks") ?? ""; + expect(hooks).toContain("node .taskless/bin/cli.mjs check"); + expect(hooks).not.toContain(""); + expect(hooks).not.toMatch(/pnpm exec taskless|npx --no taskless/); + }); + it("wires CI through the project launcher with Node from the job", () => { const ci = getRecipe("ci") ?? ""; expect(ci).toContain("node .taskless/bin/cli.mjs check"); From 05520e731d786d1d1578b2cdea4a0757039d163c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:19:32 -0700 Subject: [PATCH 12/15] docs(openspec): archive launcher-hints-and-hooks --- .changeset/pinned-cli-wrapper.md | 2 +- .../.openspec.yaml | 2 ++ .../proposal.md | 29 +++++++++++++++++++ .../specs/cli-agent/spec.md | 16 ++++++++++ .../specs/cli-project-launcher/spec.md | 20 +++++++++++++ .../specs/cli-taskless-bootstrap/spec.md | 17 +++++++++++ .../tasks.md | 17 +++++++++++ openspec/specs/cli-agent/spec.md | 15 ++++++++++ openspec/specs/cli-project-launcher/spec.md | 19 ++++++++++++ openspec/specs/cli-taskless-bootstrap/spec.md | 16 ++++++++++ 10 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/proposal.md create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-agent/spec.md create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-project-launcher/spec.md create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-taskless-bootstrap/spec.md create mode 100644 openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/tasks.md diff --git a/.changeset/pinned-cli-wrapper.md b/.changeset/pinned-cli-wrapper.md index f5a77b1b..f664f79d 100644 --- a/.changeset/pinned-cli-wrapper.md +++ b/.changeset/pinned-cli-wrapper.md @@ -2,6 +2,6 @@ "@taskless/cli": patch --- -`init` now installs a project launcher, `.taskless/bin/cli.mjs` (`cli-nightly.mjs` for a nightly), that runs the CLI version recorded for it in `.taskless/taskless.json`. The installed skill and recipes fetched through the launcher name it instead of a pinned `npx` call, so the skill no longer changes on every release, and every harness's permission file gets a version-free rule for it. The `ci` recipe now generates `node .taskless/bin/cli.mjs check`, with Node supplied by the job, so a repository without a `package.json` can run Taskless in CI. +`init` now installs a project launcher, `.taskless/bin/cli.mjs` (`cli-nightly.mjs` for a nightly), that runs the CLI version recorded for it in `.taskless/taskless.json`. The installed skill and recipes fetched through the launcher name it instead of a pinned `npx` call, so the skill no longer changes on every release, and every harness's permission file gets a version-free rule for it. The `ci` recipe now generates `node .taskless/bin/cli.mjs check`, with Node supplied by the job, so a repository without a `package.json` can run Taskless in CI. The `hooks` recipe runs the launcher too, instead of adding `@taskless/cli` as a dev dependency. When the launcher started the CLI, the commands it prints for you to run next name the launcher, and `.taskless/README.md` lists it first. Each package's Claude Code `autoMode.allow` entry now also covers its own launcher, so one time only, the wizard offers to replace the existing entry. More than one string under a package's label now counts as stale, and the wizard folds them into one. Settings edits are colored as migrations: `ALLOW` green, `REMOVE` uncolored, `REPLACE` yellow, and `SKIPPED` red, which marks an edit you have to make yourself. A missing or stale global entry after `init` prints as a red `SKIPPED (global):` line. diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/.openspec.yaml b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/.openspec.yaml new file mode 100644 index 00000000..c1bc9027 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/proposal.md b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/proposal.md new file mode 100644 index 00000000..8bc5a7a3 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/proposal.md @@ -0,0 +1,29 @@ +## Why + +`pinned-cli-wrapper` moved agents, the skill, permissions, and CI onto the project launcher, `./.taskless/bin/cli.mjs`. Two places still sent people back to `npx`. Every remedy and hint the CLI prints for a person (`Run … auth login`, `Create one with … rule create`) named `npx @taskless/cli@` even when the launcher had started the CLI. The `hooks` recipe told agents to add `@taskless/cli` as a dev dependency and run it from `node_modules/.bin`, which is a second pin to keep in step with `.taskless/taskless.json`. + +## What Changes + +- **Messages a person reads name the launcher that started the CLI.** The invocation shown in remedies and hints is the launcher's path when the launcher started the process, and the detected `npx`/`pnpm dlx` form otherwise. The two remedies that named the build's invocation directly (a pending migration, an unreadable manifest) use the same choice. +- **The scaffold README lists the project launcher first,** above the `pnpm dlx` and `npx` lines, which stay for a checkout where the launcher is not there yet. +- **The `hooks` recipe runs `node .taskless/bin/cli.mjs`.** It no longer adds a dev dependency or resolves a binary from `node_modules/.bin`. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-project-launcher`: remedies and hints name the launcher that started the CLI. +- `cli-taskless-bootstrap`: the README lists the launcher first. +- `cli-agent`: the `hooks` recipe runs the launcher. + +## Impact + +- `packages/cli/src/util/package-manager.ts` (`getCliPrefix`), `filesystem/migrate.ts`, `filesystem/manifest.ts`, `filesystem/migrations/0001-init.ts`. +- `packages/cli/src/agent/hooks.md`. +- This repository's `.taskless/README.md`, regenerated to the template's output. +- Tests: `package-manager.test.ts`, `scaffold-readme.test.ts`, `prompts.test.ts`. +- Delivery shape: **single PR**, on the same pull request as `pinned-cli-wrapper`, which it finishes. diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-agent/spec.md new file mode 100644 index 00000000..18a0f020 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-agent/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: The hooks recipe runs the launcher + +The `hooks` recipe SHALL generate pre-commit commands that run `node .taskless/bin/cli.mjs`, for every hook tool it covers. It SHALL NOT tell the reader to add `@taskless/cli` as a dev dependency for the hook, SHALL NOT resolve a binary from `node_modules/.bin`, and SHALL NOT call `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`, since the launcher already runs the version recorded in `.taskless/taskless.json`. It SHALL require that `.taskless/bin/cli.mjs` exists and is committed, and SHALL say what to do when `.taskless/` is not at the repository root, where git runs hooks. + +#### Scenario: Every hook tool runs the launcher + +- **WHEN** the `hooks` recipe is rendered +- **THEN** its lint-staged, lefthook, pre-commit, and husky examples SHALL each run `node .taskless/bin/cli.mjs check` +- **AND** it SHALL NOT mention a dev dependency as a step + +#### Scenario: A missing launcher + +- **WHEN** the `hooks` recipe is followed in a project with no `.taskless/bin/cli.mjs` +- **THEN** it SHALL direct the reader to run the installer and commit the result before writing a hook diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-project-launcher/spec.md b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..51668f70 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-project-launcher/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Messages a person reads name the launcher that started the CLI + +When a project launcher started the CLI, as its environment variable reports with exactly one of the two launcher paths, every command the CLI prints for a person to run next (a remedy in an error, a hint after a command, the installer named in a `SKIPPED (global):` line) SHALL name that launcher's path. Otherwise it SHALL name the detected `npx` or `pnpm dlx` invocation, as before. A remedy that names the CLI SHALL reach it through this one choice rather than spelling the build's invocation itself. + +#### Scenario: A hint after a launcher run + +- **WHEN** `./.taskless/bin/cli.mjs check` runs in a project with no rules +- **THEN** the hint SHALL tell the reader to run `./.taskless/bin/cli.mjs rule create --from ` + +#### Scenario: A hint after an npx run + +- **WHEN** `npx @taskless/cli@0.13.0 check` runs in a project with no rules and no launcher variable set +- **THEN** the hint SHALL name `npx @taskless/cli@0.13.0` + +#### Scenario: An unexpected variable is ignored + +- **WHEN** the launcher variable is `./evil.sh` +- **THEN** printed commands SHALL NOT name `./evil.sh` diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-taskless-bootstrap/spec.md b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-taskless-bootstrap/spec.md new file mode 100644 index 00000000..fee807c4 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/specs/cli-taskless-bootstrap/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: The README lists the project launcher first + +For a build that installs a launcher, `.taskless/README.md` SHALL list that package's launcher, `./.taskless/bin/cli.mjs` for the release and `./.taskless/bin/cli-nightly.mjs` for a nightly, as the first usage example, run from the repository root, above the `pnpm dlx` and `npx` examples. The package-manager examples SHALL remain, for a checkout where the launcher is not there yet. The launcher is chosen from the package the README's specifier names, so the bytes still do not depend on how the run was launched. A path-form build, which installs no launcher, SHALL list none. + +#### Scenario: A released build lists cli.mjs first + +- **WHEN** migration `"1"` runs in a `prod` build +- **THEN** the first usage example SHALL be `./.taskless/bin/cli.mjs check` +- **AND** the `pnpm dlx` and `npx` examples SHALL follow it + +#### Scenario: A nightly lists its own launcher + +- **WHEN** migration `"1"` runs in a `nightly` build +- **THEN** the first usage example SHALL be `./.taskless/bin/cli-nightly.mjs check` +- **AND** the README SHALL NOT name `./.taskless/bin/cli.mjs` diff --git a/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/tasks.md b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/tasks.md new file mode 100644 index 00000000..872c07e4 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-launcher-hints-and-hooks/tasks.md @@ -0,0 +1,17 @@ +## 1. Messages a person reads + +- [x] 1.1 Prefer a known launcher from `TASKLESS_LAUNCHER` in `getCliPrefix()` +- [x] 1.2 Route the pending-migration and unreadable-manifest remedies through `getCliPrefix()` +- [x] 1.3 List the package's launcher first in the scaffold README; regenerate this repository's `.taskless/README.md` +- [x] 1.4 Tests: `getCliPrefix()` with a launcher and with an unexpected value; README for release and nightly + +## 2. Hooks recipe + +- [x] 2.1 Rewrite `hooks` around `node .taskless/bin/cli.mjs`, drop the dev-dependency step, require the launcher, and bump the topic version +- [x] 2.2 Test that the rendered recipe runs the launcher and names no local binary + +## 3. Release + +- [x] 3.1 Extend the `pinned-cli-wrapper` changeset +- [x] 3.2 Run `pnpm typecheck` and `pnpm lint` +- [x] 3.3 Archive the change on the PR diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index b3ff9d67..b5e87775 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -504,3 +504,18 @@ The recipe SHALL require that the generated job make Node available itself, thro - **WHEN** the `ci` recipe is followed in a repository with no `package.json` - **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json` + +### Requirement: The hooks recipe runs the launcher + +The `hooks` recipe SHALL generate pre-commit commands that run `node .taskless/bin/cli.mjs`, for every hook tool it covers. It SHALL NOT tell the reader to add `@taskless/cli` as a dev dependency for the hook, SHALL NOT resolve a binary from `node_modules/.bin`, and SHALL NOT call `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`, since the launcher already runs the version recorded in `.taskless/taskless.json`. It SHALL require that `.taskless/bin/cli.mjs` exists and is committed, and SHALL say what to do when `.taskless/` is not at the repository root, where git runs hooks. + +#### Scenario: Every hook tool runs the launcher + +- **WHEN** the `hooks` recipe is rendered +- **THEN** its lint-staged, lefthook, pre-commit, and husky examples SHALL each run `node .taskless/bin/cli.mjs check` +- **AND** it SHALL NOT mention a dev dependency as a step + +#### Scenario: A missing launcher + +- **WHEN** the `hooks` recipe is followed in a project with no `.taskless/bin/cli.mjs` +- **THEN** it SHALL direct the reader to run the installer and commit the result before writing a hook diff --git a/openspec/specs/cli-project-launcher/spec.md b/openspec/specs/cli-project-launcher/spec.md index 6480e233..5766e418 100644 --- a/openspec/specs/cli-project-launcher/spec.md +++ b/openspec/specs/cli-project-launcher/spec.md @@ -84,3 +84,22 @@ A launcher's comments SHALL state only what it does: that it runs the Taskless C - **WHEN** a launcher's source is read - **THEN** its comments SHALL name `.taskless/taskless.json` and the package it runs - **AND** SHALL NOT contain the words "bypass", "classifier", or "auto mode" + +### Requirement: Messages a person reads name the launcher that started the CLI + +When a project launcher started the CLI, as its environment variable reports with exactly one of the two launcher paths, every command the CLI prints for a person to run next (a remedy in an error, a hint after a command, the installer named in a `SKIPPED (global):` line) SHALL name that launcher's path. Otherwise it SHALL name the detected `npx` or `pnpm dlx` invocation, as before. A remedy that names the CLI SHALL reach it through this one choice rather than spelling the build's invocation itself. + +#### Scenario: A hint after a launcher run + +- **WHEN** `./.taskless/bin/cli.mjs check` runs in a project with no rules +- **THEN** the hint SHALL tell the reader to run `./.taskless/bin/cli.mjs rule create --from ` + +#### Scenario: A hint after an npx run + +- **WHEN** `npx @taskless/cli@0.13.0 check` runs in a project with no rules and no launcher variable set +- **THEN** the hint SHALL name `npx @taskless/cli@0.13.0` + +#### Scenario: An unexpected variable is ignored + +- **WHEN** the launcher variable is `./evil.sh` +- **THEN** printed commands SHALL NOT name `./evil.sh` diff --git a/openspec/specs/cli-taskless-bootstrap/spec.md b/openspec/specs/cli-taskless-bootstrap/spec.md index 1bf6c70d..df1469e7 100644 --- a/openspec/specs/cli-taskless-bootstrap/spec.md +++ b/openspec/specs/cli-taskless-bootstrap/spec.md @@ -449,3 +449,19 @@ Migration `11` SHALL create `.taskless/reports/` holding a short stub that says - **WHEN** `.taskless/reports/` already exists with records in it and `.taskless/.gitignore` already contains `/reports/`, and migration 11 runs - **THEN** every existing record SHALL be unchanged - **AND** the file SHALL contain exactly one `/reports/` line afterwards + +### Requirement: The README lists the project launcher first + +For a build that installs a launcher, `.taskless/README.md` SHALL list that package's launcher, `./.taskless/bin/cli.mjs` for the release and `./.taskless/bin/cli-nightly.mjs` for a nightly, as the first usage example, run from the repository root, above the `pnpm dlx` and `npx` examples. The package-manager examples SHALL remain, for a checkout where the launcher is not there yet. The launcher is chosen from the package the README's specifier names, so the bytes still do not depend on how the run was launched. A path-form build, which installs no launcher, SHALL list none. + +#### Scenario: A released build lists cli.mjs first + +- **WHEN** migration `"1"` runs in a `prod` build +- **THEN** the first usage example SHALL be `./.taskless/bin/cli.mjs check` +- **AND** the `pnpm dlx` and `npx` examples SHALL follow it + +#### Scenario: A nightly lists its own launcher + +- **WHEN** migration `"1"` runs in a `nightly` build +- **THEN** the first usage example SHALL be `./.taskless/bin/cli-nightly.mjs check` +- **AND** the README SHALL NOT name `./.taskless/bin/cli.mjs` From 21353a92b67c7a47896af0d1b11d5d89180d2206 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 17:31:42 -0700 Subject: [PATCH 13/15] test: stop pinning this repository's README to the template at HEAD The repo's .taskless/README.md is migration output from the pinned build that installed it, so a byte-for-byte comparison with HEAD's template forced either a hand edit of migration output or a dogfood reinstall whenever the template changed. The template is covered in scaffold-readme.test.ts and the stale-README migration by its fixture test. Restores the README the earlier commit hand-edited. --- .taskless/README.md | 3 --- .../cli/test/installed-documentation.test.ts | 22 +++++++------------ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/.taskless/README.md b/.taskless/README.md index fd229cab..e7d6fd5d 100644 --- a/.taskless/README.md +++ b/.taskless/README.md @@ -7,9 +7,6 @@ This directory contains [Taskless](https://taskless.io) configuration and rules Run the Taskless scanner from your repository root: ```sh -# the project launcher, from the repository root -./.taskless/bin/cli.mjs check - # npm / pnpm pnpm dlx @taskless/cli@latest check diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index beeb8b75..aac1f0aa 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -4,8 +4,6 @@ import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildReadmeContent } from "../src/filesystem/migrations/0001-init"; -import { readmeSpecifier } from "../src/util/package-manager"; import { ensureTasklessDirectory } from "../src/filesystem/directory"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; import { @@ -95,18 +93,14 @@ describe("migration 0006, on a project that is already current", () => { }); describe("this repository's own installed Taskless docs", () => { - it("carries the README this build would write", async () => { - const onDisk = await readFile( - resolve(repositoryRoot, ".taskless", "README.md"), - "utf8" - ); - - // If this fails, the template changed and this project was not migrated. - // Run `pnpm build && pnpm cli init` and commit the result; - // do not edit `.taskless/README.md` by hand, since the next migration - // overwrites it. - expect(onDisk).toBe(buildReadmeContent(readmeSpecifier())); - }); + // There is deliberately no byte-for-byte comparison of this repository's + // `.taskless/README.md` with the template at HEAD. The file is migration + // output from whichever CLI last installed this repository, a pinned + // published build, so a template change would fail it until someone either + // hand-edited migration output or reinstalled the dogfood setup (moving its + // recorded version, permissions, and skill) just to satisfy a test. The + // template itself is covered in `scaffold-readme.test.ts`, and the migration + // that refreshes a stale README by the fixture test above. it("describes no directory the layout migrations removed", async () => { // The specific stale words, named so a reintroduction is caught by what it From 510e79380b14f037cb61b60d8976ce1dcb92fd9f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 19:29:13 -0700 Subject: [PATCH 14/15] docs(openspec): record the Cursor CLI launcher rule check --- .../changes/archive/2026-10-19-pinned-cli-wrapper/design.md | 2 +- openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md index 76d1bc02..523c4f98 100644 --- a/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md +++ b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md @@ -58,7 +58,7 @@ What a manual auto-mode probe showed on 2026-10-19. Later runs of the in-repo sc - [An agent whose shell is not at the project root cannot run `./.taskless/bin/cli.mjs`] → The skill already requires each Taskless call to start its own line from the project root; keep that wording and test it against the launcher. - [A launcher comment can get an agent refused] → The spec forbids comments that frame the launcher as a way around a check; a test asserts it. - [Someone edits the launcher in a pull request] → `init` restores it and reports it; review catches the rest. This is the same exposure as any script in the repository. -- [Harness rules match a path-valued command differently] → Researched 2026-10-19. Codex `prefix_rule` compares argv tokens by whole-string equality with no normalization of a relative first token (`codex-rs/execpolicy/src/rule.rs`), so `["./.taskless/bin/cli.mjs"]` matches exactly the spelling the skill uses and nothing longer. Cursor CLI's only shell form is `Shell(commandBase)`, where the base is the command line's first token and a bare base allows any arguments; the Cursor editor's `terminalAllowlist` is a case-sensitive, word-bounded prefix; OpenCode turns a trailing ` *` into an optional argument group anchored at both ends. Neither Cursor doc says whether a path-valued base is compared literally or by basename, which task 3.1 checks with a local run. The launcher is always spelled `./.taskless/bin/cli.mjs`, so one rule per harness is enough; a second rule for the bare `.taskless/bin/cli.mjs` spelling would allow a form nothing generates. +- [Harness rules match a path-valued command differently] → Researched 2026-10-19. Codex `prefix_rule` compares argv tokens by whole-string equality with no normalization of a relative first token (`codex-rs/execpolicy/src/rule.rs`), so `["./.taskless/bin/cli.mjs"]` matches exactly the spelling the skill uses and nothing longer. Cursor CLI's only shell form is `Shell(commandBase)`, where the base is the command line's first token and a bare base allows any arguments; the Cursor editor's `terminalAllowlist` is a case-sensitive, word-bounded prefix; OpenCode turns a trailing ` *` into an optional argument group anchored at both ends. Neither Cursor doc says whether a path-valued base is compared literally or by basename. A local run of the Cursor CLI (task 3.1) settled it: the first word is compared exactly as typed, with no reduction to a file name, no raw prefix match and no `./` normalization, and a command run through `node` is judged as `node`. The editor was not run, and is taken to follow its documented word-bounded prefix. The launcher is always spelled `./.taskless/bin/cli.mjs`, so one rule per harness is enough; a second rule for the bare `.taskless/bin/cli.mjs` spelling would allow a form nothing generates. - [Global entries written before this change read as stale] → The wizard already offers `REPLACE (global):` once; no new mechanism. ## Migration Plan diff --git a/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md index 50467104..d9935150 100644 --- a/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md +++ b/openspec/changes/archive/2026-10-19-pinned-cli-wrapper/tasks.md @@ -17,7 +17,7 @@ ## 3. Permissions -- [ ] 3.1 Confirm with a local run what the docs leave open: that Cursor CLI compares a path-valued `Shell(./.taskless/bin/cli.mjs)` base literally rather than by basename, and that neither Cursor surface normalizes `./`. Codex is settled from source and confirmed locally with `codex execpolicy check` on 2026-10-19 (matches `./.taskless/bin/cli.mjs` with and without arguments; not `cli.mjsx`, not `.taskless/bin/cli.mjs`, not the nightly's launcher); drop any harness that fails and update the spec table +- [x] 3.1 Confirm with a local run what the docs leave open: that Cursor CLI compares a path-valued `Shell(./.taskless/bin/cli.mjs)` base literally rather than by basename, and that neither Cursor surface normalizes `./`. Codex is settled from source and confirmed locally with `codex execpolicy check` on 2026-10-19 (matches `./.taskless/bin/cli.mjs` with and without arguments; not `cli.mjsx`, not `.taskless/bin/cli.mjs`, not the nightly's launcher); drop any harness that fails and update the spec table. Cursor CLI confirmed on 2026-10-19 (`cursor-agent` v2026.10.19-6bbf2c4, allowlist mode): it compares the command's first word exactly as typed, so `Shell(./.taskless/bin/cli.mjs)` auto-runs the launcher with any arguments and asks for `./other/cli.mjs`, `./.taskless/bin/cli.mjsx`, `.taskless/bin/cli.mjs` and `node .taskless/bin/cli.mjs` (judged as `node`). The Cursor editor was not run; its documented word-bounded prefix match is taken to behave the same for this spelling - [x] 3.2 Add the launcher rule per harness beside the invocation rules, and map `cli.mjs` / `cli-nightly.mjs` to their packages when classifying Taskless rules - [x] 3.3 Tests: launcher rule written, not reported as an edit on upgrade, removed with its package when a harness is deselected, other package's launcher rule kept From 2021a9db94b9cd324c3edb2bdd2536d2db89a356 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 23:24:20 -0700 Subject: [PATCH 15/15] test(launcher): cover the cmd.exe quoting for npx.cmd off Windows --- packages/cli/test/launcher.test.ts | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index 22fb721f..09088f42 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import process from "node:process"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -88,6 +89,11 @@ function reported(stdout: string): { }; } +/** One argument as the launcher quotes it for a batch file such as npx.cmd. */ +function quoted(inner: string): string { + return `^^^"${inner}^^^"`; +} + describe.skipIf(process.platform === "win32")("project launcher", () => { it("runs the release's recorded version with every argument", async () => { const path = await install("@taskless/cli", { @@ -175,6 +181,53 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { expect(result.signal).toBe("SIGTERM"); }); + it("quotes every argument for cmd.exe when it runs npx.cmd on Windows", async () => { + const path = await install("@taskless/cli", { + install: { launchers: { "@taskless/cli": "0.13.0" } }, + }); + // Pretend to be Windows, and stand in for cmd.exe with a script that + // reports the command line it was handed, so the quoting runs anywhere. + const preload = join(root, "as-windows.mjs"); + await writeFile( + preload, + 'Object.defineProperty(process, "platform", { value: "win32" });\n' + ); + const comspec = join(fakeBin, "cmd"); + await writeFile(comspec, FAKE_NPX); + await chmod(comspec, 0o755); + + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + path, + "check", + "src/a file.ts", + 'say "hi" & calc', + "C:\\dir\\", + ], + { cwd: root, encoding: "utf8", env: { ...process.env, comspec } } + ); + expect(result.status).toBe(0); + // Each argument is quoted, then every metacharacter is escaped twice: + // once for cmd.exe and once for the batch file's own parse. + expect(reported(result.stdout).argv).toEqual([ + "/d", + "/s", + "/c", + `"${[ + "npx", + quoted("--yes"), + quoted("@taskless/cli@0.13.0"), + quoted("check"), + quoted("src/a^^^ file.ts"), + quoted(String.raw`say^^^ \^^^"hi\^^^"^^^ ^^^&^^^ calc`), + quoted("C:\\dir\\\\"), + ].join(" ")}"`, + ]); + }); + it.each([ ["no manifest", undefined], ["a manifest that does not parse", "{"],