-
Notifications
You must be signed in to change notification settings - Fork 0
feat: resolve the CLI invocation as a recipe variable #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
94b48cf
feat(cli): resolve the CLI invocation as a recipe variable
theCodeDrift 6a3249c
fix(cli): report header-less recipe variables from the returned text
theCodeDrift 578c6d2
fix(cli): detect the launcher from argv, not the user agent
theCodeDrift 2b5e570
fix(cli): require the real pnpm dlx cache shape, not a bare dlx segment
theCodeDrift c418418
refactor(cli): name the CLI by invocation in nine recipes
theCodeDrift 1ee565e
fix(cli): keep ci.txt's two non-invocation commands out of %(TASKLESS…
theCodeDrift e40d554
refactor(cli): finish naming the CLI by invocation everywhere
theCodeDrift e478534
test(cli): check cross-references on rendered recipes, guard the source
theCodeDrift 8117fbc
test(cli): derive the guard's inputs instead of restating them
theCodeDrift e18676c
test(cli): exempt the two ci.txt lines where a literal invocation is …
theCodeDrift 99b97e4
fix(cli): clear the four deferred review items
theCodeDrift 2861d6f
fix(cli): resolve the invocation on the onboard path, tighten the npx…
theCodeDrift File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "@taskless/cli": minor | ||
| --- | ||
|
|
||
| Name the CLI by its full invocation everywhere an agent is told to run it. | ||
|
|
||
| Agent recipes said `taskless agent route` — a binary almost nobody has on `PATH` — in 114 places, `npx @taskless/cli …` in 40 more, and only the second form was rewritten for non-prod builds. A nightly's recipes therefore sent readers to the released package. All of it now renders through one new sprintf variable, `%(TASKLESS_CLI)s`, which resolves to a caller-supplied invocation, else the build's own invocation when that build is not prod, else the agent-fill marker `<taskless-cli>`. | ||
|
|
||
| `@taskless/cli/prompts` gains `getInstructions(topic, options?)` and `getRawInstructions(topic, options?)`, both returning `{ text, variables }`. The raw form hands back the unrendered template and the list of variables it contains, so a host that knows its own launcher can render the text itself; `variables` comes from sprintf-js's own parse rather than a regex over the template. `PromptOptions.invocation` is the only way a consumer sets `TASKLESS_CLI` — the render path stays free of `process` so it remains importable from a Worker. | ||
|
|
||
| Fixes launcher detection in user-facing error messages. `getCliPrefix()` read only `npm_config_user_agent`, which every pnpm entry point sets, so running the CLI from a `package.json` script told the user to run `pnpm dlx @taskless/cli@latest`. Detection now reads the path the binary was launched from, recognizes npx and `pnpm dlx` only, and answers "unknown" for everything else. The package specifier comes from the build target, so a nightly's error messages name `@taskless/cli-nightly` at its own version. |
2 changes: 2 additions & 0 deletions
2
openspec/changes/archive/2026-08-23-full-cli-invocation/.openspec.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-08-23 |
45 changes: 45 additions & 0 deletions
45
openspec/changes/archive/2026-08-23-full-cli-invocation/proposal.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| ## Why | ||
|
|
||
| A recipe that says `` `taskless agent route` `` is telling the reader to run a binary that is, for most readers, not on `PATH`. Nobody installs `@taskless/cli` globally — it is reached through `npx` or `pnpm dlx` — so the shortest correct form of that line is `npx @taskless/cli@latest agent route`, and the recipes say it 154 times in three different ways: | ||
|
|
||
| | Form | Occurrences | Where | | ||
| | ------------------------- | ----------- | --------------------- | | ||
| | `` `taskless <cmd>` `` | 114 | 20 of 21 recipe files | | ||
| | `npx @taskless/cli <cmd>` | 40 | 13 recipe files | | ||
| | `%(PACKAGE_MANAGER_DLX)s` | 6 | `ci.txt` only | | ||
|
|
||
| Three spellings of one fact is a drift surface, and it is already drifting: `applyCliInvocation` rewrites only the second form, so on a `nightly` or `build:self` artifact the 114 bare occurrences keep naming a binary that build deliberately did not install. An agent reading a nightly's `route` recipe is told to run `taskless agent create-sg-rule`, which resolves to whatever `@taskless/cli` happens to be on the machine — the released package, not the nightly it is supposed to be exercising. The `dev`/`self` build notice exists to paper over exactly this, and it papers over one form out of three. | ||
|
|
||
| Underneath the recipes, the CLI's own answer to "how was I called" is wrong. `getCliPrefix()` (`packages/cli/src/util/package-manager.ts:8-20`) reads `npm_config_user_agent` and nothing else. Every pnpm entry point sets a `pnpm/` user agent — `pnpm run`, `pnpm exec`, `pnpm dlx`, and every lifecycle script — so a CLI invoked from a `package.json` script inside a pnpm repo tells the user to run `pnpm dlx @taskless/cli@latest auth login`. That is not how they got there and, in a repo that pins the CLI as a devDependency, not what they want. The function also hardcodes `@taskless/cli@latest`, so a nightly's error messages send the reader to the released package — the same bug the nightly build target was created to fix, in the one code path that never got the fix. | ||
|
|
||
| A user agent cannot answer this question. It reports which package manager is in the process tree, not which command the user typed. What distinguishes `pnpm dlx` from `pnpm run` is the path the binary was launched from: `pnpm dlx` runs out of `~/Library/Caches/pnpm/dlx/<hash>/…`, `npx` out of `~/.npm/_npx/<hash>/…`, and `pnpm run` out of the repo's own `node_modules/.bin`. `process.argv[1]` carries that, and it is the signal this change reads. | ||
|
|
||
| ## What Changes | ||
|
|
||
| - **Add a `%(TASKLESS_CLI)s` sprintf variable** carrying the full invocation — launcher, package name, and version pin — and normalize all 154 hardcoded call sites in `packages/cli/src/agent/*.txt`, `skills/taskless/SKILL.md`, and `commands/tskl/tskl.md` onto it. | ||
| - **Resolve it in three steps**: an explicit `RecipeOptions.invocation`, else the build-target invocation `__TASKLESS_CLI__` when that build is not prod, else the agent-fill marker `<taskless-cli>` — matching the `<package-manager-dlx>` convention already in the renderer. A prod build that cannot tell how it was launched emits a marker rather than a guess. | ||
| - **Detect the launcher from `process.argv[1]` and the environment, as a pure function over an injected context**, the way `resolveBuildTarget` is pure over an injected `BuildEnvironment`. It answers `npx`, `pnpm dlx`, or "not confident" — and "not confident" is a first-class answer, not a fallback to npx. Yarn, bun, and global installs are deliberately not detected: they set no signal distinguishable from a bare `node` launch, and the marker is the honest answer for them. | ||
| - **Fix `getCliPrefix()`** to use that detection and to derive its package spec from the build target, so a nightly's error messages name `@taskless/cli-nightly@<version>`. Five call sites (`auth/identity.ts:29`, `auth/token.ts:121`, `api/rules.ts:40,79`, `commands/check.ts:172`) are corrected by the change with no edit. | ||
| - **Export `getInstructions` and `getRawInstructions`** from `@taskless/cli/prompts`, each returning `{ text, variables }`. `getRawInstructions` returns the unrendered template plus the list of variables it contains, so a host that knows its own package manager can render the text itself. Both throw on an unknown topic, matching `getPrompt`; `getRecipe` keeps returning `undefined` because the `agent` command and its tests depend on that. | ||
| - **Collect the variable list from sprintf's own parser, not a regex.** `sprintf-js` exports no parser, but its named-argument lookup is property access — so rendering against a `Proxy` makes sprintf report which names it asked for. Per `.conventions/STYLEGUIDE-CODE.md`, this asks the thing that already parsed the template instead of re-deriving it with a weaker tool. The proxy pass's _output_ is discarded: sprintf collapses `%%` to `%` irreversibly during parse, so only the source template is re-renderable. | ||
|
|
||
| **The detection is passed in, never read.** `src/prompts/` must stay importable by a Worker without `nodejs_compat`, where a module-scope `process.env` read throws at import time. `assertPromptsGraph` in `vite.config.ts` would not catch that — `process` is a global, not an import — so the constraint is architectural: the CLI detects and passes `invocation`; a host that imports `@taskless/cli/prompts` passes nothing and gets the marker. | ||
|
|
||
| ## Capabilities | ||
|
|
||
| ### Modified Capabilities | ||
|
|
||
| - `cli-agent`: the sprintf variable table gains `TASKLESS_CLI`, and recipes are required to name the CLI through it rather than as a bare binary or a hardcoded `npx` string. | ||
| - `cli-knowledge-prompts`: the export gains `getInstructions`/`getRawInstructions` and the `invocation` option, and the rendering guarantee extends to the new variable. | ||
| - `cli`: launcher detection becomes a specified behavior — argv-shaped, pure, and allowed to answer "unknown". | ||
|
|
||
| ## Impact | ||
|
|
||
| - **Modified**: `packages/cli/src/prompts/recipes.ts` (variable table, raw/rendered split, variable collection), `packages/cli/src/prompts/index.ts` (two new exports), `packages/cli/src/util/package-manager.ts` (rewritten around argv detection), `packages/cli/src/commands/agent.ts` (passes the detected invocation), all 21 files under `packages/cli/src/agent/`, `skills/taskless/SKILL.md`, `commands/tskl/tskl.md`. | ||
| - **Tests**: `test/package-manager.test.ts` is rewritten — its premise (user agent is sufficient) is what this change refutes. `test/recipe-cross-references.test.ts` moves from scanning recipe _source_ to scanning _rendered_ recipes, because a `%(TASKLESS_CLI)s agent <topic>` citation is invisible to a regex anchored on a package name. New: raw/rendered round-trip per topic, `%%` preservation, table-driven detection, prod-build-renders-a-marker, and a guard that no bare `` `taskless <cmd>` `` returns to the recipe sources. | ||
| - **Unchanged**: `getPrompt`, `PROMPTS`, `TOPICS`, `INTERNAL_TOPICS`, and every existing `PromptOptions` field. `getRecipe` keeps its `undefined`-on-unknown contract. | ||
| - **Out of scope**: detecting yarn and bun launchers; changing `PACKAGE_MANAGER_DLX`, which answers a different question (what the _consuming repo's_ CI should type) and stays a marker. | ||
|
|
||
| **Delivery shape: stacked, merging DOWN, in five PRs.** The units are only correct together. A recipe carrying `%(TASKLESS_CLI)s` without the renderer fails `test/prompts.test.ts`'s unresolved-placeholder assertion, and the renderer alone ships a variable no recipe uses while `test/recipe-cross-references.test.ts` still scans source for a spelling that is being removed. There is no ordering in which an intermediate `main` is both green and coherent, so the stack merges down from the tip and reaches `main` as one merge. | ||
|
|
||
| **Tracking:** taskless/cli#141 |
86 changes: 86 additions & 0 deletions
86
openspec/changes/archive/2026-08-23-full-cli-invocation/specs/cli-agent/spec.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| ## MODIFIED Requirements | ||
|
|
||
| ### Requirement: Recipe substitution uses sprintf-js named arguments | ||
|
|
||
| Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named-argument form (`%(KEY)s`). The renderer SHALL build a variables table for each render call containing two flavors of substitution: | ||
|
|
||
| 1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic. | ||
| 2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as `<package-manager-dlx>`). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention. | ||
|
|
||
| The renderer SHALL additionally provide `TASKLESS_CLI` for every render. It is a hybrid of the two flavors: system-resolved when the caller or the build knows the answer, and an agent-fill marker when neither does. It SHALL resolve in this order: | ||
|
|
||
| 1. The caller-supplied invocation, when one is given. | ||
| 2. The build-target invocation, when the build target is not prod — a `nightly`, `dev`, or `self` build knows exactly what it is and SHALL name itself. | ||
| 3. Otherwise the agent-fill marker `<taskless-cli>`. | ||
|
|
||
| Step 3 SHALL NOT fall back to `npx @taskless/cli`. A prod build that does not know how it was launched has no basis for naming one launcher over another, and a marker asks the reading agent for the answer instead of asserting a wrong one. | ||
|
|
||
| Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table. | ||
|
|
||
| #### Scenario: CLI_VERSION substitutes the build-time version | ||
|
|
||
| - **WHEN** any recipe is rendered | ||
| - **THEN** every `%(CLI_VERSION)s` occurrence SHALL be replaced with the build-time CLI version | ||
|
|
||
| #### Scenario: INPUT_SCHEMA substitutes only when present in the recipe | ||
|
|
||
| - **WHEN** a recipe contains `%(INPUT_SCHEMA)s` | ||
| - **THEN** it SHALL be replaced with the JSON Schema rendered from the topic's Zod schema | ||
| - **AND** when no Zod schema is registered for the topic, the placeholder SHALL render as `(no input schema for this topic)` | ||
|
|
||
| #### Scenario: PACKAGE_MANAGER_DLX renders as an agent-fill marker | ||
|
|
||
| - **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s` | ||
| - **THEN** the rendered output SHALL contain the literal token `<package-manager-dlx>` at every occurrence | ||
|
|
||
| #### Scenario: TASKLESS_CLI renders a caller-supplied invocation | ||
|
|
||
| - **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with an explicit invocation | ||
| - **THEN** every occurrence SHALL render as that invocation | ||
|
|
||
| #### Scenario: TASKLESS_CLI names a non-prod build target | ||
|
|
||
| - **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with no explicit invocation from a `nightly`, `dev`, or `self` build | ||
| - **THEN** every occurrence SHALL render as that build's own invocation, so a nightly names `@taskless/cli-nightly` at its published version rather than the released package | ||
|
|
||
| #### Scenario: TASKLESS_CLI falls back to an agent-fill marker | ||
|
|
||
| - **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered from a prod build with no explicit invocation | ||
| - **THEN** every occurrence SHALL render as the literal token `<taskless-cli>` | ||
| - **AND** SHALL NOT render as `npx @taskless/cli` or any other guessed launcher | ||
|
|
||
| #### Scenario: No legacy placeholder syntax remains in recipes | ||
|
|
||
| - **WHEN** any `<topic>.txt` file under `packages/cli/src/agent/` is read | ||
| - **THEN** it SHALL NOT contain a `{{KEY}}` mustache-style placeholder | ||
| - **AND** all substitution SHALL be expressed as `%(KEY)s` sprintf-js named arguments | ||
|
|
||
| ## ADDED Requirements | ||
|
|
||
| ### Requirement: Recipes name the CLI by its full invocation | ||
|
|
||
| Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL express the CLI as `%(TASKLESS_CLI)s`, followed by the subcommand and its arguments. This applies to the recipe sources under `packages/cli/src/agent/`, to `skills/taskless/SKILL.md`, and to `commands/tskl/tskl.md`. | ||
|
|
||
| Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers, and SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli`, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. | ||
|
|
||
| An automated check SHALL fail when a bare `` `taskless <subcommand>` `` invocation appears in a recipe source, so the normalization cannot silently regress as recipes are edited. | ||
|
|
||
| #### Scenario: A recipe instructs the reader to run a subcommand | ||
|
|
||
| - **WHEN** a recipe tells the reader to fetch another topic | ||
| - **THEN** the source SHALL read `%(TASKLESS_CLI)s agent <topic>` rather than `taskless agent <topic>` or `npx @taskless/cli agent <topic>` | ||
|
|
||
| #### Scenario: A regression is reintroduced | ||
|
|
||
| - **WHEN** a recipe source is edited to contain a bare `` `taskless check` ``-style invocation | ||
| - **THEN** the automated check SHALL fail and name the offending file and line | ||
|
|
||
| #### Scenario: Non-prod builds rewrite every invocation, not some of them | ||
|
|
||
| - **WHEN** a `nightly` or `self` build renders any recipe | ||
| - **THEN** every CLI invocation in the rendered text SHALL name that build's own invocation, with no occurrence left naming the released `@taskless/cli` | ||
|
|
||
| #### Scenario: Cross-reference checking survives the normalization | ||
|
|
||
| - **WHEN** the check that recipe cross-references cite only topics that resolve is run | ||
| - **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.