Skip to content

feat: run the Taskless CLI through a project launcher - #537

Open
theCodeDrift wants to merge 15 commits into
mainfrom
pinned-cli-wrapper
Open

theCodeDrift wants to merge 15 commits into
mainfrom
pinned-cli-wrapper

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Stack (root → tip):

init now installs .taskless/bin/cli.mjs (cli-nightly.mjs for a nightly), a small Node script that runs the CLI version recorded for it in .taskless/taskless.json. Agents, the skill, permissions, and CI call that one path instead of npx @taskless/cli@<version>.

Why

Claude Code's auto-mode classifier refuses npx @taskless/cli@<version> as downloaded code, even with a project allow rule. A manual probe on 2026-10-19 showed the same call made from a script in the repository, ./.taskless/bin/cli.mjs, runs. It ran consistently, because the classifier treats files the user brought into the repository as theirs to run. With an allowance naming the launcher, it was reliable. node .taskless/bin/cli.mjs was refused as an external script, so agents use the ./ spelling.

It also takes the version out of the installed skill, so the skill no longer changes on every release, and gives CI in any language one command whose version comes from the project.

What changes

  • The launcher (packages/cli/assets/launcher/launcher.mjs): reads its package's version from install.launchers, falling back to install.cliVersion only when that is its own package's version. It refuses anything that is not an exact version, runs npx --yes <package>@<version> with the arguments unchanged, and passes the exit status and signals back. On Windows it quotes arguments for cmd the way cross-spawn does. Its comments describe what it does and nothing more, since an agent that reads a comment framing it as a way past a check gets refused.
  • Install: each package writes only its own launcher and records only its own version, so the release and a nightly never move each other. An edited launcher is restored, and the summary and --json envelope say so. The skill renders %(TASKLESS_CLI)s to the launcher, and says to run it from the directory that contains .taskless/. Stub recovery commands keep the npx form.
  • Permissions: every harness gets a version-free launcher rule beside its pinned npx rule. Launcher paths count as Taskless rules of their package.
  • Global trust entry: each package's autoMode.allow entry also covers its own launcher, so existing entries read as stale once. More than one string under a label is now stale too, and the wizard folds them into one under the same confirmation. --no-interactive still writes nothing outside the project.
  • Settings edits are colored as migrations: ALLOW green, REMOVE uncolored, REPLACE yellow, SKIPPED red. Red now only means "you have to do this yourself". A missing or stale global entry after init prints as a red SKIPPED (global): line.
  • Recipes: a recipe fetched through the launcher names it. The CLI believes TASKLESS_LAUNCHER only when it is exactly one of the two paths. The ci recipe generates node .taskless/bin/cli.mjs check with Node supplied by the job, so a Python or Go repository can run it without a package.json. Its template now sets Node 22, matching the CLI's engine floor (it said 20).
  • Hooks: the hooks recipe runs node .taskless/bin/cli.mjs in every hook tool. It no longer adds @taskless/cli as a dev dependency, which was a second pin to keep in step with .taskless/taskless.json.
  • Commands printed for a person: when the launcher started the CLI, remedies and hints name ./.taskless/bin/cli.mjs instead of npx. This goes through getCliPrefix(), plus the two remedies that bypassed it (pending migration, unreadable manifest). .taskless/README.md lists the package's launcher first, keeping the pnpm dlx and npx lines for a checkout without it. This repository's own .taskless/README.md is left as its installed CLI wrote it; the test that pinned it to HEAD's template is removed, since satisfying it meant hand-editing migration output or reinstalling the dogfood setup.

Verification

  • Checks: pnpm typecheck, pnpm lint, and the full suite pass (134 files, 2435 tests).
  • New tests:
    • the launcher, run against a fake npx;
    • release and nightly installs side by side;
    • permission rules per harness;
    • duplicate folding;
    • colors;
    • recipe rendering through the launcher.
  • End to end: init --no-interactive from the built CLI into a fresh project, then ./.taskless/bin/cli.mjs info and ../.taskless/bin/cli.mjs check from a subdirectory. Both ran the published 0.13.1. With the launcher variable set, the built CLI's no-rules hint names ./.taskless/bin/cli.mjs rule create; with an unexpected value, it falls back to npx.
  • Codex: codex execpolicy check confirms prefix_rule(pattern = ["./.taskless/bin/cli.mjs"]) matches the launcher with and without arguments. It does not match cli.mjsx, the spelling without ./, or the nightly's launcher.
  • Cursor CLI: run in allowlist mode with only Shell(./.taskless/bin/cli.mjs). The launcher auto-ran with and without arguments. ./other/cli.mjs, ./.taskless/bin/cli.mjsx, .taskless/bin/cli.mjs and node .taskless/bin/cli.mjs all asked. Cursor compares the command's first word exactly as typed. The Cursor editor wasn't run; its documented word-bounded prefix match is taken to behave the same.
  • Windows: the shebang run through Git Bash was confirmed on a Windows machine.
  • Archive: checked against the main specs. No existing scenario was dropped (cli-init 141 → 151, agent-global-trust 25 → 32, cli-agent 58 → 66, skill-taskless 28 → 30, cli-taskless-bootstrap 51 → 53).

Follow-ups (not blocking)

  • A smoke test of the nightly's launcher in an auto-mode session with the new global entry.

Delivery shape: single PR. The launcher, its permissions, and the invocation change are only useful together. Both OpenSpec changes, pinned-cli-wrapper and launcher-hints-and-hooks, are archived here.

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.
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/.
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/.
…n 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.
…licates

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.
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.
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.
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.
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.
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.
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.
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @theCodeDrift's task in 5m 24s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no review threads, no review summaries, and no top-level comments other than the trigger comment itself, so this is the first review pass on this PR; everything below is assessed fresh against the whole diff.

  • Read prior review data (.prior-review.json) — empty, first pass
  • Fetch PR diff and metadata (gh pr diff, gh pr view)
  • Review launcher script (packages/cli/assets/launcher/launcher.mjs) and install logic (install/launcher.ts, install/install.ts)
  • Review permissions / global-trust changes (install/permissions.ts, install/global-trust.ts, install/settings-tone.ts)
  • Review supporting changes (util/package-manager.ts, util/invocation.ts, filesystem/manifest.ts, filesystem/migrate.ts, filesystem/migrations/0001-init.ts, commands/init.ts, wizard/index.ts, wizard/steps/summary.ts, prompts/recipes.ts)
  • Cross-check tests for the new behavior
  • Post findings

Scope of this pass: this is a large, carefully-reasoned change (new project launcher, manifest schema for per-package launcher versions, permission-rule/global-trust-entry updates for every harness, settings-tone recoloring, recipe/doc rewrites). I traced the full execution paths for: the launcher template itself (version resolution, Windows cmd.exe quoting, exit/signal passthrough); the install-time launcher write/restore logic; the Claude/Cursor CLI/Codex/OpenCode permission-rule generation and denial-matching now covering both the pinned invocation and the launcher; the global-trust duplicate-entry folding and text-replacement-by-index logic (verified the reversed-order removal keeps earlier indices valid); and the TASKLESS_LAUNCHER env-var detection path feeding getCliPrefix()/detectCliInvocation(). All of it held together correctly — I did not find a correctness bug.

One non-blocking finding (posted inline):

  • packages/cli/assets/launcher/launcher.mjs — quoteForCmd(), the Windows cmd.exe/batch-file double-escaping, has no automated test. test/launcher.test.ts skips its entire describe block on win32, so the only coverage for this function is the manual Windows run noted in the PR description. The algorithm itself is a correct, faithful port of cross-spawn's quoting routine, so this is a test-coverage suggestion, not a bug report — a pure string-in/string-out unit test of quoteForCmd wouldn't need Windows to run and would let CI catch a future regression.

Things I specifically checked and found sound (noting since several are easy to get subtly wrong):

  • Per-package launcher isolation: buildLauncherPackage()/writeLauncher() only ever touch the current build's own file, and applyInstallPlan only updates its own key in install.launchers, so a nightly install can't move the release's recorded version (and vice versa) — matches the "side by side" tests.
  • The launcher's version fallback to install.cliVersion is correctly gated by the nightly/release prerelease-hyphen check, so an older manifest doesn't let a nightly launcher accidentally run a release pin or vice versa.
  • global-trust.ts's duplicate-folding inspect(): removes later-indexed duplicate strings in descending order after replacing the first occurrence in place, so earlier indices stay valid across the edits — correct.
  • permissions.ts's launcher-aware denial checks (allowedCommands/sampleCommands/coversAny) and the Cursor CLI bare-Shell(base) rule (added specifically because a launcher has no fixed trailing arguments) are applied consistently across all four harnesses.
  • getCliPrefix()/detectCliInvocation() reading TASKLESS_LAUNCHER only match it against a fixed two-value set before trusting it, and getCliPrefix() doesn't touch the manifest, so it stays safe to call from the "unreadable manifest" error path.

No findings were carried over from a previous review since none exists yet.

Comment thread packages/cli/assets/launcher/launcher.mjs
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @github-actions — "Claude finished @theCodeDrift's task in 5m 24s…"
#537 (comment)

Thanks. The one finding (no CI coverage for quoteForCmd) is addressed in 2021a9d with a test that forces the Windows path off Windows; no other changes needed.

— AI Coding Agent

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant