Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/pinned-cli-wrapper.md
Original file line number Diff line number Diff line change
@@ -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. 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.
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19
Original file line number Diff line number Diff line change
@@ -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@<version>` 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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <file>`

#### 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`
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19
70 changes: 70 additions & 0 deletions openspec/changes/archive/2026-10-19-pinned-cli-wrapper/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
## Context

See proposal.md for why. The installed skill, project permission rules, and recipes all name the CLI as `npx @taskless/cli@<version>` today. That string is baked into the build (`resolveCliInvocation`), rendered into the canonical skill, and detected again at `agent <topic>` 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. 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 |
| ---------------------------------------------------------------------- | ----------------------------- |
| `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 <path>` and allowed `./<path>`, so the agent-facing spelling is the direct one. CI has no classifier, and there `node <path>` 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.

**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 <cache>/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 <topic>` 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.

**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 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.
- [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

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.
Loading
Loading