From d4028d2c6009391b7a56c756e941bae684507077 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 10 Sep 2026 15:43:03 -0700 Subject: [PATCH 1/4] feat(cli): tell an agent what init changed, and that recipes are per-task `init --no-interactive` ends with an upgrade trailer naming the directories that hold changed files and, after a version move, pointing at `update`. The --json envelope carries the same as `cliVersion`, `targets`, and `changed`. A canonical file whose bytes already match the bundle is no longer rewritten or reported, so a no-op re-install reads as one. The migration refusal names `init --no-interactive` for a piped caller, and the init recipe is rewritten for the agent that runs it. Every recipe carries a fetch-time directive on its second line, stripped with the version under `header: false`; the skill and tskl command say a recipe is fetched again for each task. Archives openspec change init-upgrade-context. --- .changeset/init-upgrade-context.md | 5 + commands/tskl/tskl.md | 4 + .../.openspec.yaml | 2 + .../2026-09-10-init-upgrade-context/design.md | 54 ++++++++ .../proposal.md | 40 ++++++ .../specs/cli-agent/spec.md | 33 +++++ .../specs/cli-init/spec.md | 88 ++++++++++++ .../specs/cli-knowledge-prompts/spec.md | 20 +++ .../specs/skill-taskless/spec.md | 58 ++++++++ .../2026-09-10-init-upgrade-context/tasks.md | 41 ++++++ openspec/specs/cli-agent/spec.md | 14 +- openspec/specs/cli-init/spec.md | 64 ++++++++- openspec/specs/cli-knowledge-prompts/spec.md | 6 +- openspec/specs/skill-taskless/spec.md | 6 + packages/cli/src/agent/auth.md | 1 + packages/cli/src/agent/check.md | 1 + packages/cli/src/agent/ci.md | 1 + packages/cli/src/agent/create-legacy-rule.md | 1 + packages/cli/src/agent/create-remote-rule.md | 1 + packages/cli/src/agent/create-runtime-rule.md | 1 + packages/cli/src/agent/create-sg-rule.md | 1 + packages/cli/src/agent/create-vale-rule.md | 1 + packages/cli/src/agent/delete-rule.md | 1 + packages/cli/src/agent/detect.md | 1 + .../cli/src/agent/improve-rule.anonymous.md | 1 + packages/cli/src/agent/improve-rule.md | 1 + packages/cli/src/agent/info.md | 1 + packages/cli/src/agent/init.md | 131 +++++++++++++----- packages/cli/src/agent/onboard.md | 1 + packages/cli/src/agent/route.md | 1 + packages/cli/src/agent/rule-meta.md | 1 + packages/cli/src/agent/rule.md | 1 + packages/cli/src/agent/update.md | 1 + packages/cli/src/agent/verify-rule.md | 1 + packages/cli/src/commands/init.ts | 88 ++++++++++-- packages/cli/src/filesystem/migrate.ts | 7 +- packages/cli/src/install/canonical.ts | 43 ++++-- packages/cli/src/install/install.ts | 16 ++- packages/cli/src/install/reload-notice.ts | 2 +- packages/cli/src/install/upgrade-trailer.ts | 58 ++++++++ packages/cli/src/prompts/recipes.ts | 21 +-- packages/cli/test/apply-install-plan.test.ts | 26 +++- packages/cli/test/canonical-store.test.ts | 25 +++- packages/cli/test/init-no-interactive.test.ts | 83 +++++++++++ .../cli/test/installed-documentation.test.ts | 22 +++ packages/cli/test/migrated-envelope.test.ts | 69 +++++++++ .../cli/test/no-implicit-migration.test.ts | 8 ++ packages/cli/test/prompts.test.ts | 42 +++++- skills/taskless/SKILL.md | 8 ++ 49 files changed, 1013 insertions(+), 90 deletions(-) create mode 100644 .changeset/init-upgrade-context.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/design.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md create mode 100644 openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md create mode 100644 packages/cli/src/install/upgrade-trailer.ts diff --git a/.changeset/init-upgrade-context.md b/.changeset/init-upgrade-context.md new file mode 100644 index 00000000..502c3cd7 --- /dev/null +++ b/.changeset/init-upgrade-context.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless init --no-interactive` now tells an agent what an install changed and what follows. The human output gains an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical file whose bytes already match the bundle is no longer rewritten or reported as written. The `SCAFFOLD_MIGRATION_REQUIRED` refusal names `init --no-interactive` when stdout is not a TTY. The `init` recipe is rewritten for the agent that runs it. Every recipe carries a fetch-time directive on its second line, stripped along with the version by `header: false`, and the skill and `tskl` command say a recipe is fetched again for each task. diff --git a/commands/tskl/tskl.md b/commands/tskl/tskl.md index 5fdbb98b..4dc729a1 100644 --- a/commands/tskl/tskl.md +++ b/commands/tskl/tskl.md @@ -25,6 +25,10 @@ Otherwise, follow the same flow as the `taskless` skill: explicitly asked for anonymous mode). 3. Follow the recipe step-by-step. The recipe is canonical for the currently-installed CLI version; do not improvise from prior knowledge. +4. Fetch again next time. A recipe is resolved when it is fetched, from the + installed version, the auth state, and the project layout at that moment. + A copy fetched earlier in this session is not a substitute, even for the + same topic. ## Topics diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/.openspec.yaml b/openspec/changes/archive/2026-09-10-init-upgrade-context/.openspec.yaml new file mode 100644 index 00000000..e8cda9e5 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md new file mode 100644 index 00000000..2bff7de1 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md @@ -0,0 +1,54 @@ +## Context + +`taskless init --no-interactive` is the install path an agent takes. It ends with a per-target summary, a migration notice on stderr, a reload banner when the version moved, and the onboarding trailer. Under `--json` it prints `{ success, commandsInstalled, migrated? }`. The `init` recipe addresses a human. Recipes open with a one-line `# Topic:` header that `PromptOptions.header: false` strips. The skill body tells an agent to fetch a recipe before acting but says nothing about fetching it again. + +## Goals / Non-Goals + +**Goals** + +- An agent that ran `init` can tell, from stdout or the envelope alone, that files changed, where, and whether `update` is the next step. +- The `init` recipe reads as instructions for the agent that runs the command. +- A recipe and the skill both say the recipe is not reusable across tasks. + +**Non-Goals** + +- Committing on the agent's behalf. `init` reports; it does not run git. +- Reworking the onboarding trailer or the reload banner. Both keep their position and wording. +- Changing the interactive wizard's output. Its reader is a person watching a terminal. +- A general "session state" mechanism for recipes. The directive is prose. + +## Decisions + +### The upgrade trailer sits before the onboarding trailer + +The onboarding-trailer requirement makes that trailer the final line of output, and several tests pin it. The upgrade trailer prints after the install summary and reload banner, and before the onboarding line. An agent reads all of stdout, so ordering costs nothing; keeping the existing requirement intact avoids retesting six scenarios for a cosmetic reorder. + +### Changed directories are named, not files + +The install result reports skill and command names per target, not paths, and deriving paths would re-implement the layout the install module already knows. The trailer names target directories (`.claude/`, `.taskless/`) plus `.taskless/` when a migration ran, which is what a `git add` needs. Migration file paths are already listed in the migration notice, and on the envelope under `migrated.files`. + +### The envelope mirrors the human summary rather than a new shape + +`targets` on the envelope is the per-target summary with the same fields the human path prints, which closes the gap the existing code comment describes ("this per-target summary is not on that envelope, so rather than drop it, it goes to stderr"). `cliVersion.previous` is `null` rather than absent when nothing was recorded, so a consumer can distinguish "fresh project" by value. `changed` is derivable from the other fields and is included anyway: it is the single value an agent gates its commit step on, and asking each consumer to fold three lists and a presence check is how one of them gets it wrong. + +### The version-moved condition is the reload banner's + +`getReloadNotice` already answers "did this run move the recorded version" with `previousCliVersion !== undefined && previousCliVersion !== cliVersion`. The `update` pointer uses the same test, so the banner and the pointer never disagree about whether an upgrade happened. + +### The refusal picks its wording from `stdout.isTTY` + +`requireCurrentSchema` has no argument for interactivity and adding one to every reader would thread a flag through `check`, `verify`, and `test` for a single string. Reading `process.stdout.isTTY` in the message builder is the same test `init` itself uses to decide between wizard and batch, so the refusal and the command it points at agree. + +### The directive is the header block's second line + +Putting the directive in the header rather than the body keeps two properties: a header-less rendering (the cache-stable prompt) does not carry an instruction to run a CLI the consumer may not have, and the directive is dropped and added by one function. `stripHeader` changes from "first line plus one blank" to "through the first blank line", with the same first-line anchor so a `# Topic:` inside a fenced example is still untouched. The `.txt` spelling in the format requirement was stale (the files are `.md`); the MODIFIED block corrects it. + +### The skill directive is a section, budgeted against the cap + +The body is 72 lines against an 80-line cap. The directive is a heading and five lines. The `tskl` command has no cap and gets the same paragraph. + +## Risks / Trade-offs + +- **Tests that pin `init --json` to an exact object** fail on the new fields. Those are updated to assert the fields they care about, and the new fields get their own assertions. No consumer parses `init --json` outside this repository's tests today. +- **Every recipe file changes by one line**, which bumps nothing (the directive is not a meaningful recipe change) but shows up as 20 one-line diffs. The topic versions stay put except `init`, whose content changed. +- **A recipe rendered with `header: false` loses the directive.** That is the intent: that rendering is for a consumer embedding the text in its own prompt, where "re-run the CLI" is the wrong instruction. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md new file mode 100644 index 00000000..0cea4730 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md @@ -0,0 +1,40 @@ +## Why + +An agent that runs `taskless init --no-interactive`, usually because `check` refused a project whose scaffold is behind the CLI, is told what was written and then pointed at onboarding. Nothing tells it that the rewrite touched files under version control and belongs in its commit, or that an upgrade of the CLI is the moment `taskless update` exists for. Separately, nothing in the skill or in a recipe says a recipe is resolved at fetch time, so an agent that fetched `agent check` once in a session reuses that text for every later task in the same session, including after the very upgrade that changed it. + +Both gaps are in agent-facing text and output shape that ship in the bundle, so they land together as one change. + +## What Changes + +- `taskless init --no-interactive` reports an upgrade's consequences, not only its writes. On the human path, after the install summary and before the onboarding trailer, it prints an upgrade trailer whenever the run changed anything: which directories now hold changed files and that they belong in the next commit, and, when the recorded CLI version moved, that `taskless update` reports what the upgrade means for existing rules. Under `--json`, the envelope carries the same facts as fields: `cliVersion: { previous, installed }`, a per-target `targets` list of what was written and removed (the summary that today goes only to stderr), and a `changed` boolean. +- The `init` recipe is rewritten for its actual reader. Today it says "the user runs this themselves"; its most common caller is an agent that `check` just sent there. It describes the non-interactive path, the envelope, and the three things that follow an install: commit the rewritten files, run `update` after a version move, and treat a session that predates the install as holding stale skills. +- The `SCAFFOLD_MIGRATION_REQUIRED` refusal names `init --no-interactive` for a non-TTY caller, so an agent does not reach for the wizard. +- Recipes state that they are not reusable across tasks. Every recipe's header block gains a second line saying the text was resolved at fetch time and that the next task fetches it again. `PromptOptions.header: false` strips the whole header block, so a cache-stable prompt is unchanged in shape. +- The skill body and the `tskl` command carry the same directive: fetch the recipe for every Taskless task, even one already fetched earlier in the session. + +Nothing here is **BREAKING**. The `init --json` envelope only gains fields; the human output only gains lines before an existing final line; the recipe body after the header block is unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: a new requirement for the upgrade trailer and the `--json` fields it mirrors; the existing onboarding-trailer requirement is unchanged (the upgrade trailer prints before it). +- `cli-agent`: the recipe format's header becomes a two-line header block, the second line being the fetch-time directive. +- `cli-knowledge-prompts`: `header: false` suppresses the header block, not only its first line. +- `skill-taskless`: the skill body includes a re-fetch directive. + +## Impact + +- `packages/cli/src/commands/init.ts`: trailer and envelope fields on the non-interactive path. +- `packages/cli/src/filesystem/migrate.ts`: the refusal message. +- `packages/cli/src/agent/*.md`: one header line per recipe; `init.md` rewritten and bumped to topic v2. +- `packages/cli/src/prompts/recipes.ts`: header stripping covers the block. +- `skills/taskless/SKILL.md`, `commands/tskl/tskl.md`: the directive. +- Tests under `packages/cli/test/` for each of the above. +- A `patch` changeset: the package is `0.y.z`, and nothing here is something a consumer must react to. + +Delivery shape: **single PR**. The diff is agent-facing prose, one command's output, and their tests, and it does not depend on any other open branch. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md new file mode 100644 index 00000000..60c3f177 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Recipe files follow a consistent format + +Every recipe file at `packages/cli/src/agent/.md` SHALL follow the canonical recipe template: a header block, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. + +The header block SHALL be exactly two lines followed by one blank line. The first line SHALL be `# Topic: (CLI v%(CLI_VERSION)s / topic v)`. The second line SHALL be the fetch-time directive, identical across every recipe: it states that the text was resolved by the CLI at the moment it was fetched and that a later task, in the same session or another, fetches it again with `%(TASKLESS_CLI)s agent ` rather than reusing this copy. The directive is part of the header so that a cache-stable rendering can drop it together with the version line. + +Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. + +#### Scenario: Recipe contains all template sections + +- **WHEN** any `.md` file is read +- **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer +- **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order + +#### Scenario: Every recipe carries the fetch-time directive as its second line + +- **WHEN** any `.md` file is read +- **THEN** its second line SHALL be the fetch-time directive, byte-identical to the directive in every other recipe +- **AND** the third line SHALL be blank + +#### Scenario: Recipe with --from input includes JSON schema placeholder + +- **WHEN** a topic recipe documents a CLI invocation that uses `--from ` +- **THEN** the recipe SHALL contain an `## Input schema` section with a code-fenced block containing the `%(INPUT_SCHEMA)s` placeholder +- **AND** the JSON Schema SHALL be derived at render time from the corresponding Zod schema in `packages/cli/src/schemas/` + +#### Scenario: Header version reflects build-time CLI version + +- **WHEN** the CLI bundle is built +- **THEN** the recipe header's `%(CLI_VERSION)s` placeholder SHALL be substituted at render time from `packages/cli/package.json` +- **AND** SHALL match the version reported by `taskless info` diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md new file mode 100644 index 00000000..809f274c --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md @@ -0,0 +1,88 @@ +## ADDED Requirements + +### Requirement: Non-interactive init reports what an upgrade changed and what follows + +`taskless init --no-interactive` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. + +On the human path, when the run changed anything (a migration ran, or any target had a skill or command written or removed), the CLI SHALL print an upgrade trailer AFTER the install summary and any reload notice and BEFORE the onboarding trailer, so the onboarding trailer stays the final line. The upgrade trailer SHALL: + +- name each directory that now holds changed files (every target directory with a write or removal, and `.taskless/` when a migration ran or the recorded version moved), and state that those files belong in the next commit; +- when the recorded `install.cliVersion` moved (a previous version was recorded and differs from the one this run recorded), state the transition and that `taskless update` reports what the upgrade means for existing rules; +- be omitted entirely when nothing changed. A no-op re-install has nothing to commit and nothing to reconcile. + +Under `--json`, the envelope SHALL carry the same facts as fields rather than prose: + +- `cliVersion`: `{ previous: string | null, installed: string }`, where `previous` is the `install.cliVersion` read before this run and `null` when none was recorded; +- `targets`: one entry per install target, `{ dir, mode, writtenSkills, writtenCommands, removedSkills, removedCommands }`, with the four lists holding names, so the per-target summary that the human path prints is on the envelope instead of on stderr; +- `changed`: `true` when a migration ran, any target list above is non-empty, or the recorded version moved. A version move rewrites `install.cliVersion` in `.taskless/taskless.json`, a tracked file, so it is a change even when every skill byte already matched. + +The `migrated` field is unchanged: present with the migration report when a migration ran, absent otherwise. + +#### Scenario: An upgrade prints the trailer with both parts + +- **WHEN** `taskless init --no-interactive` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub +- **THEN** stdout SHALL contain an upgrade trailer naming each directory that changed and saying those files belong in the next commit +- **AND** the trailer SHALL name the previous and installed versions and point at `taskless update` +- **AND** the onboarding trailer SHALL still be the final line + +#### Scenario: A change without a version move omits the update pointer + +- **WHEN** the run writes or removes files but the recorded `install.cliVersion` is the running version +- **THEN** the upgrade trailer SHALL name the changed directories and the commit obligation +- **AND** SHALL NOT mention `taskless update` + +#### Scenario: A no-op re-install prints no upgrade trailer + +- **WHEN** `taskless init --no-interactive` runs against a project that is already at the current scaffold version and whose every target reports up to date +- **THEN** stdout SHALL NOT contain the upgrade trailer + +#### Scenario: The JSON envelope carries the version, targets, and changed flag + +- **WHEN** `taskless init --no-interactive --json` runs +- **THEN** the envelope SHALL contain `cliVersion.previous` (a string or `null`), `cliVersion.installed`, a `targets` array with one entry per install target, and a boolean `changed` +- **AND** `changed` SHALL be `true` exactly when `migrated` is present, any target's written or removed list is non-empty, or `cliVersion.previous` is non-null and differs from `cliVersion.installed` + +### Requirement: The migration refusal names the non-interactive path for a non-TTY caller + +When a read-only command refuses a project whose scaffold is behind the CLI, the refusal SHALL tell the caller to run `init --no-interactive` when stdout is not a TTY, and `init` otherwise. An agent that follows the message verbatim SHALL never be sent to the wizard. + +#### Scenario: A non-TTY caller is pointed at the non-interactive install + +- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is not a TTY +- **THEN** the message SHALL contain `init --no-interactive` + +#### Scenario: A TTY caller is pointed at the wizard + +- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is a TTY +- **THEN** the message SHALL contain `init` and SHALL NOT contain `--no-interactive` + +## MODIFIED Requirements + +### Requirement: Update rewrites canonical content and preserves reference stubs + +`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. A canonical file whose bytes already equal what the bundle would write SHALL be left untouched and SHALL NOT be reported as written: the install summary, the `--json` `targets` field, and the upgrade trailer all describe what changed on disk, and a byte-identical rewrite is not a change. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. + +Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. + +#### Scenario: Update refreshes canonical content + +- **WHEN** `taskless update` runs against an install with a newer bundled skill version +- **THEN** `.taskless/skills/taskless/SKILL.md` SHALL be rewritten with the new content + +#### Scenario: An unchanged canonical file is not reported as written + +- **WHEN** the install runs and `.taskless/skills/taskless/SKILL.md` already holds exactly what the bundle would write +- **THEN** the file SHALL NOT be rewritten +- **AND** the run's report SHALL NOT list it as written for the `.taskless` target + +#### Scenario: Update does not clobber a reference stub + +- **WHEN** `taskless update` runs and `.claude/skills/taskless/SKILL.md` is an existing reference stub +- **THEN** update SHALL NOT replace it with full canonical content +- **AND** the stub SHALL continue to delegate to `.taskless/skills/taskless/SKILL.md` + +#### Scenario: Update never destroys the canonical store + +- **WHEN** `taskless update` processes its targets +- **THEN** it SHALL NOT delete `.taskless/skills/` or `.taskless/commands/` as part of cleaning up any target +- **AND** the canonical content SHALL remain readable throughout the update diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..d2bc4cc2 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: The version header is suppressible + +Rendered prompts SHALL begin with a header block: a line naming the topic and the CLI version, then the fetch-time directive line, then one blank line. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing the block. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. Suppression SHALL remove the whole block, so a header-less rendering neither carries the version nor opens with a directive that only makes sense beside it. + +#### Scenario: Header suppressed for a cache-stable system prompt + +- **WHEN** a consumer calls a prompt with `header: false` +- **THEN** the returned text omits the `# Topic: …` line and the directive line and contains no CLI version string, while the body is otherwise identical to the default rendering + +#### Scenario: Header present by default + +- **WHEN** a prompt is called with no options, or the `agent` command renders a topic +- **THEN** the header block is present, exactly as it renders today + +#### Scenario: Build defines are inlined into the prompts entry + +- **WHEN** a rendered prompt is inspected from the built `dist/prompts.js` +- **THEN** it contains no un-inlined build-define identifier (e.g. a literal `__VERSION__`) diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md new file mode 100644 index 00000000..f7c12422 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md @@ -0,0 +1,58 @@ +## MODIFIED Requirements + +### Requirement: Skill body is a router, not an inline recipe + +The consolidated skill body SHALL NOT contain step-by-step instructions for any individual Taskless task. The body SHALL be a router that: + +1. States explicitly that the agent does NOT have the steps for any Taskless action in its context. +2. Instructs the agent to fetch the canonical recipe via `npx @taskless/cli help ` before proceeding. +3. Provides a topic disambiguation table mapping user intents to topic names. The table SHALL include a row for the new `onboard` topic. +4. Includes a `## --anonymous` section explaining the global flag's behavior. +5. Includes a first-step `.taskless/` presence check with graceful failure ("ask the user to confirm they meant Taskless"). +6. Includes a `## Quiet suggestion` (or equivalently named) section governing the proactive trigger introduced via the description's named-tool clause. This section SHALL specify that: + - When the skill triggers because the user wants to add a rule and has not named a specific tool, the agent SHALL surface a single-line offer to capture the rule via Taskless rather than launching into a full recipe (e.g., "I can capture this as a Taskless rule if you want — say so, or I'll proceed with X"). + - If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill. + - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. + - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. +7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. The `tskl` command body SHALL carry the same statement. + +The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) + +#### Scenario: Skill body warns against improvising + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" + +#### Scenario: Skill body forbids reusing a fetched recipe + +- **WHEN** the skill body or the `tskl` command body is read by an agent +- **THEN** it SHALL state that a recipe is resolved at fetch time and that each Taskless task fetches its recipe again, even for a topic fetched earlier in the session + +#### Scenario: Skill body lists available topics including onboard + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL include a table or list mapping user intents to the corresponding `tskl agent ` invocations +- **AND** the table SHALL include a row for `onboard` mapped to `npx @taskless/cli agent onboard` (or equivalent invocation of the onboard topic) + +#### Scenario: Skill body checks for .taskless directory + +- **WHEN** the skill is invoked +- **THEN** the body's first step SHALL instruct the agent to check whether `.taskless/` exists in the working directory +- **AND** to ask the user to confirm Taskless is what they meant if the directory is absent + +#### Scenario: Skill body specifies quiet suggestion behavior + +- **WHEN** the skill is triggered by the unspecified-tool clause from the description +- **THEN** the body's quiet-suggestion section SHALL instruct the agent to surface a single-line offer rather than a full recipe +- **AND** SHALL instruct the agent NOT to re-offer in the same conversation if declined +- **AND** SHALL specify that no persistent decline state is written + +#### Scenario: Skill body specifies in-conversation decline is sticky + +- **WHEN** the user has declined a quiet-suggestion offer once in the current conversation +- **THEN** the body SHALL instruct the agent not to surface the offer again in the same conversation + +#### Scenario: Skill body length cap + +- **WHEN** the skill body is measured +- **THEN** it SHALL be no more than 80 lines of markdown diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md new file mode 100644 index 00000000..83153038 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md @@ -0,0 +1,41 @@ +# Tasks + +**Delivery shape: single PR.** Agent-facing prose, one command's output, and +their tests. It touches no open branch and each part is small enough that +splitting would separate a change from the test that pins it. + +## 1. Non-interactive init reports the upgrade + +- [x] 1.1 Add an upgrade-trailer renderer beside `getOnboardTrailer`, taking the changed directories, whether a migration ran, and the previous/installed versions. Return `undefined` when nothing changed, so the no-op case is decided in one place +- [x] 1.2 Reuse the reload banner's version-moved test for the `update` pointer, rather than writing a second comparison next to it +- [x] 1.3 In `runNonInteractive`, collect per-target results into a `targets` list (`dir`, `mode`, four name lists) and derive `changed` from it plus `migrated`. Return `previousCliVersion` and `cliVersion` alongside so the caller does not re-read state +- [x] 1.4 Print the trailer after the reload notice and before the onboarding trailer on the human path; add `cliVersion`, `targets`, and `changed` to the `--json` envelope, keeping `migrated` presence-gated as it is +- [x] 1.5 Tests: upgrade with version move (both parts, onboarding trailer still last), change without version move (no `update` pointer), no-op re-install (no trailer), and the envelope fields with `changed` agreeing with `migrated` and the lists. Update any test that pins the envelope to an exact object + +## 2. The migration refusal names the right command + +- [x] 2.1 In `requireCurrentSchema`'s refusal, append `--no-interactive` when `process.stdout.isTTY` is not `true` +- [x] 2.2 Test both wordings through the built CLI; the existing `no-implicit-migration` suite is the place, since it already runs `check` against a behind-the-CLI fixture with a piped stdout + +## 3. The init recipe addresses the agent + +- [x] 3.1 Rewrite `init.md` (topic v2): the non-interactive invocation as the primary step, the `--json` envelope, and the three follow-ups (commit the named directories, run `update` after a version move, treat a session that predates the install as holding stale skills). Keep the wizard description for a human reader under its own heading +- [x] 3.2 Keep `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also` in order so the format test still passes + +## 4. Recipes carry the fetch-time directive + +- [x] 4.1 Add the directive as line 2 of every `packages/cli/src/agent/*.md`, byte-identical, using `%(TASKLESS_CLI)s agent ` for the re-fetch command +- [x] 4.2 Extend `stripHeader` to drop through the first blank line, keeping the first-line `# Topic:` anchor. Verify `header: false` output contains neither the version nor the directive and that the body is byte-identical to the default rendering's body +- [x] 4.3 Add a test that every recipe's second line is the directive and third line is blank, so a new recipe cannot omit it + +## 5. The skill and command say a recipe is per-task + +- [x] 5.1 Add a short section to `skills/taskless/SKILL.md` stating a recipe is resolved at fetch time and each task fetches again, even for a topic already fetched in the session. Stay under the 80-line cap +- [x] 5.2 Add the same statement to `commands/tskl/tskl.md` +- [x] 5.3 Test through the installed documentation suite that both bodies contain the statement + +## 6. Ship + +- [x] 6.1 `patch` changeset for `@taskless/cli`, saying what an agent now sees after `init` and that recipes carry the directive +- [x] 6.2 `pnpm build && pnpm typecheck && pnpm lint && pnpm test` +- [x] 6.3 Archive the change on this PR, then run the pre-archive scenario check from CLAUDE.md against each of the three MODIFIED requirements diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 05fd648c..4c0a68df 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -55,14 +55,24 @@ Recipe files SHALL be located at `packages/cli/src/agent/` as plain `.txt` files ### Requirement: Recipe files follow a consistent format -Every recipe file at `packages/cli/src/agent/.txt` SHALL follow the canonical recipe template: a single-line header `# Topic: (CLI v%(CLI_VERSION)s / topic v)`, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. +Every recipe file at `packages/cli/src/agent/.md` SHALL follow the canonical recipe template: a header block, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. + +The header block SHALL be exactly two lines followed by one blank line. The first line SHALL be `# Topic: (CLI v%(CLI_VERSION)s / topic v)`. The second line SHALL be the fetch-time directive, identical across every recipe: it states that the text was resolved by the CLI at the moment it was fetched and that a later task, in the same session or another, fetches it again with `%(TASKLESS_CLI)s agent ` rather than reusing this copy. The directive is part of the header so that a cache-stable rendering can drop it together with the version line. + +Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. #### Scenario: Recipe contains all template sections -- **WHEN** any `.txt` file is read +- **WHEN** any `.md` file is read - **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer - **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order +#### Scenario: Every recipe carries the fetch-time directive as its second line + +- **WHEN** any `.md` file is read +- **THEN** its second line SHALL be the fetch-time directive, byte-identical to the directive in every other recipe +- **AND** the third line SHALL be blank + #### Scenario: Recipe with --from input includes JSON schema placeholder - **WHEN** a topic recipe documents a CLI invocation that uses `--from ` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index ae5873f6..a7800573 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -666,7 +666,7 @@ Each target entry in `.taskless/taskless.json` install state SHALL record a `mod ### Requirement: Update rewrites canonical content and preserves reference stubs -`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. +`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. A canonical file whose bytes already equal what the bundle would write SHALL be left untouched and SHALL NOT be reported as written: the install summary, the `--json` `targets` field, and the upgrade trailer all describe what changed on disk, and a byte-identical rewrite is not a change. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. @@ -675,6 +675,12 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di - **WHEN** `taskless update` runs against an install with a newer bundled skill version - **THEN** `.taskless/skills/taskless/SKILL.md` SHALL be rewritten with the new content +#### Scenario: An unchanged canonical file is not reported as written + +- **WHEN** the install runs and `.taskless/skills/taskless/SKILL.md` already holds exactly what the bundle would write +- **THEN** the file SHALL NOT be rewritten +- **AND** the run's report SHALL NOT list it as written for the `.taskless` target + #### Scenario: Update does not clobber a reference stub - **WHEN** `taskless update` runs and `.claude/skills/taskless/SKILL.md` is an existing reference stub @@ -752,3 +758,59 @@ An install SHALL also reclaim a stub whose recovery command names a build other - **WHEN** an install finds a stub whose body carries no recovery instruction - **THEN** the install SHALL rewrite that stub - **AND** a subsequent install SHALL leave the rewritten stub untouched + +### Requirement: Non-interactive init reports what an upgrade changed and what follows + +`taskless init --no-interactive` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. + +On the human path, when the run changed anything (a migration ran, or any target had a skill or command written or removed), the CLI SHALL print an upgrade trailer AFTER the install summary and any reload notice and BEFORE the onboarding trailer, so the onboarding trailer stays the final line. The upgrade trailer SHALL: + +- name each directory that now holds changed files (every target directory with a write or removal, and `.taskless/` when a migration ran or the recorded version moved), and state that those files belong in the next commit; +- when the recorded `install.cliVersion` moved (a previous version was recorded and differs from the one this run recorded), state the transition and that `taskless update` reports what the upgrade means for existing rules; +- be omitted entirely when nothing changed. A no-op re-install has nothing to commit and nothing to reconcile. + +Under `--json`, the envelope SHALL carry the same facts as fields rather than prose: + +- `cliVersion`: `{ previous: string | null, installed: string }`, where `previous` is the `install.cliVersion` read before this run and `null` when none was recorded; +- `targets`: one entry per install target, `{ dir, mode, writtenSkills, writtenCommands, removedSkills, removedCommands }`, with the four lists holding names, so the per-target summary that the human path prints is on the envelope instead of on stderr; +- `changed`: `true` when a migration ran, any target list above is non-empty, or the recorded version moved. A version move rewrites `install.cliVersion` in `.taskless/taskless.json`, a tracked file, so it is a change even when every skill byte already matched. + +The `migrated` field is unchanged: present with the migration report when a migration ran, absent otherwise. + +#### Scenario: An upgrade prints the trailer with both parts + +- **WHEN** `taskless init --no-interactive` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub +- **THEN** stdout SHALL contain an upgrade trailer naming each directory that changed and saying those files belong in the next commit +- **AND** the trailer SHALL name the previous and installed versions and point at `taskless update` +- **AND** the onboarding trailer SHALL still be the final line + +#### Scenario: A change without a version move omits the update pointer + +- **WHEN** the run writes or removes files but the recorded `install.cliVersion` is the running version +- **THEN** the upgrade trailer SHALL name the changed directories and the commit obligation +- **AND** SHALL NOT mention `taskless update` + +#### Scenario: A no-op re-install prints no upgrade trailer + +- **WHEN** `taskless init --no-interactive` runs against a project that is already at the current scaffold version and whose every target reports up to date +- **THEN** stdout SHALL NOT contain the upgrade trailer + +#### Scenario: The JSON envelope carries the version, targets, and changed flag + +- **WHEN** `taskless init --no-interactive --json` runs +- **THEN** the envelope SHALL contain `cliVersion.previous` (a string or `null`), `cliVersion.installed`, a `targets` array with one entry per install target, and a boolean `changed` +- **AND** `changed` SHALL be `true` exactly when `migrated` is present, any target's written or removed list is non-empty, or `cliVersion.previous` is non-null and differs from `cliVersion.installed` + +### Requirement: The migration refusal names the non-interactive path for a non-TTY caller + +When a read-only command refuses a project whose scaffold is behind the CLI, the refusal SHALL tell the caller to run `init --no-interactive` when stdout is not a TTY, and `init` otherwise. An agent that follows the message verbatim SHALL never be sent to the wizard. + +#### Scenario: A non-TTY caller is pointed at the non-interactive install + +- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is not a TTY +- **THEN** the message SHALL contain `init --no-interactive` + +#### Scenario: A TTY caller is pointed at the wizard + +- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is a TTY +- **THEN** the message SHALL contain `init` and SHALL NOT contain `--no-interactive` diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index 3dba677f..25dd60b2 100644 --- a/openspec/specs/cli-knowledge-prompts/spec.md +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -83,17 +83,17 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub ### Requirement: The version header is suppressible -Rendered prompts SHALL begin with a header line naming the topic and the CLI version. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing it. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. +Rendered prompts SHALL begin with a header block: a line naming the topic and the CLI version, then the fetch-time directive line, then one blank line. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing the block. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. Suppression SHALL remove the whole block, so a header-less rendering neither carries the version nor opens with a directive that only makes sense beside it. #### Scenario: Header suppressed for a cache-stable system prompt - **WHEN** a consumer calls a prompt with `header: false` -- **THEN** the returned text omits the `# Topic: …` line and contains no CLI version string, while the body is otherwise identical to the default rendering +- **THEN** the returned text omits the `# Topic: …` line and the directive line and contains no CLI version string, while the body is otherwise identical to the default rendering #### Scenario: Header present by default - **WHEN** a prompt is called with no options, or the `agent` command renders a topic -- **THEN** the header line is present, exactly as it renders today +- **THEN** the header block is present, exactly as it renders today #### Scenario: Build defines are inlined into the prompts entry diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index dd694bb9..cfdc4f04 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -73,6 +73,7 @@ The consolidated skill body SHALL NOT contain step-by-step instructions for any - If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill. - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. +7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. The `tskl` command body SHALL carry the same statement. The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) @@ -81,6 +82,11 @@ The body SHALL be no more than 80 lines of markdown to keep the always-loaded su - **WHEN** the skill body is read by an agent - **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" +#### Scenario: Skill body forbids reusing a fetched recipe + +- **WHEN** the skill body or the `tskl` command body is read by an agent +- **THEN** it SHALL state that a recipe is resolved at fetch time and that each Taskless task fetches its recipe again, even for a topic fetched earlier in the session + #### Scenario: Skill body lists available topics including onboard - **WHEN** the skill body is read by an agent diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index 001aacf2..df4681a1 100644 --- a/packages/cli/src/agent/auth.md +++ b/packages/cli/src/agent/auth.md @@ -1,4 +1,5 @@ # Topic: auth (CLI v%(CLI_VERSION)s / topic v1) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Manage Taskless authentication. Three branches: diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index 5f0129d4..d8ba970c 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -1,4 +1,5 @@ # Topic: check (CLI v%(CLI_VERSION)s / topic v2) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Run the applicable rules against the codebase and report matches. Two diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 61ca69e2..ac496e61 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,5 @@ # Topic: ci (CLI v%(CLI_VERSION)s / topic v1) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Wire `%(TASKLESS_CLI)s check` into the user's existing CI so rules run diff --git a/packages/cli/src/agent/create-legacy-rule.md b/packages/cli/src/agent/create-legacy-rule.md index d12a7b0e..718d2184 100644 --- a/packages/cli/src/agent/create-legacy-rule.md +++ b/packages/cli/src/agent/create-legacy-rule.md @@ -1,4 +1,5 @@ # Topic: create-legacy-rule (CLI v%(CLI_VERSION)s / topic v1) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-legacy-rule`. It helps you write a rule for a linter the diff --git a/packages/cli/src/agent/create-remote-rule.md b/packages/cli/src/agent/create-remote-rule.md index 82720e5c..51e63973 100644 --- a/packages/cli/src/agent/create-remote-rule.md +++ b/packages/cli/src/agent/create-remote-rule.md @@ -1,4 +1,5 @@ # Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v3) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-remote-rule`. It helps you have the Taskless service diff --git a/packages/cli/src/agent/create-runtime-rule.md b/packages/cli/src/agent/create-runtime-rule.md index 3a485a82..046f7d58 100644 --- a/packages/cli/src/agent/create-runtime-rule.md +++ b/packages/cli/src/agent/create-runtime-rule.md @@ -1,4 +1,5 @@ # Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v2) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-runtime-rule`. It helps you write a runtime rule: a diff --git a/packages/cli/src/agent/create-sg-rule.md b/packages/cli/src/agent/create-sg-rule.md index 40e41359..df8b2e51 100644 --- a/packages/cli/src/agent/create-sg-rule.md +++ b/packages/cli/src/agent/create-sg-rule.md @@ -1,4 +1,5 @@ # Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v5) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-sg-rule`. It helps you write an ast-grep rule: a check diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index b937dfa8..c1392dec 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -1,4 +1,5 @@ # Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v7) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-vale-rule`. It helps you write a Vale rule: a check over diff --git a/packages/cli/src/agent/delete-rule.md b/packages/cli/src/agent/delete-rule.md index 5cda434e..18d4c828 100644 --- a/packages/cli/src/agent/delete-rule.md +++ b/packages/cli/src/agent/delete-rule.md @@ -1,4 +1,5 @@ # Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v4) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Remove a rule and its associated test files from `.taskless/`. Does not diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index faf3e7f8..1e3ba567 100644 --- a/packages/cli/src/agent/detect.md +++ b/packages/cli/src/agent/detect.md @@ -1,4 +1,5 @@ # Topic: detect (CLI v%(CLI_VERSION)s / topic v1) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Scan the working directory for the linters it configures, the diff --git a/packages/cli/src/agent/improve-rule.anonymous.md b/packages/cli/src/agent/improve-rule.anonymous.md index 8088b7a9..ef1e99cc 100644 --- a/packages/cli/src/agent/improve-rule.anonymous.md +++ b/packages/cli/src/agent/improve-rule.anonymous.md @@ -1,4 +1,5 @@ # Topic: improve-rule (anonymous) (CLI v%(CLI_VERSION)s / topic v2) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Iterate on an existing ast-grep rule **locally** without contacting diff --git a/packages/cli/src/agent/improve-rule.md b/packages/cli/src/agent/improve-rule.md index 449de825..fa45a247 100644 --- a/packages/cli/src/agent/improve-rule.md +++ b/packages/cli/src/agent/improve-rule.md @@ -1,4 +1,5 @@ # Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v5) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Iterate on an existing Taskless rule. The CLI submits the user's diff --git a/packages/cli/src/agent/info.md b/packages/cli/src/agent/info.md index 127b223a..528e8e4b 100644 --- a/packages/cli/src/agent/info.md +++ b/packages/cli/src/agent/info.md @@ -1,4 +1,5 @@ # Topic: info (CLI v%(CLI_VERSION)s / topic v1) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Report local Taskless state: CLI version, installed skill versions diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 7853dac1..3d18c137 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,54 +1,111 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v1) +# Topic: init (CLI v%(CLI_VERSION)s / topic v2) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal -Install or update the Taskless skill into the user's coding-agent -tools (Claude Code, OpenCode, Cursor, etc.). The user runs this -themselves, the agent's role is mostly to point the user at the -right command when they need to install or upgrade. +Install or update the Taskless skill in this project, and migrate the +`.taskless/` layout when the project is behind the CLI. You most often +arrive here because `check`, `verify`, or `test` refused with +`SCAFFOLD_MIGRATION_REQUIRED`: those commands only read, so the rewrite +is left to `init`, which is the one command that migrates. + +An install rewrites files under version control and can change what an +upgrade means for the rules already in the project. Running the command +is the first of three steps, not the whole job. ## Preconditions -- None at the user level. The command works in any directory and +- None at the project level. The command works in any directory and bootstraps `.taskless/` on first run. -- For interactive mode: a TTY (running from a terminal). +- No auth. `init` never calls the Taskless API. +- The wizard needs a TTY. You do not have one, so use the flag below. ## Steps -The user should run: -``` -%(TASKLESS_CLI)s -``` -(no subcommand). In a TTY this launches the interactive wizard. In -non-TTY contexts it prints the topic index instead. - -For scripted installs (CI, Dockerfiles): -``` -%(TASKLESS_CLI)s init --no-interactive -``` - -The wizard will: -1. Detect installed tools (Claude Code, OpenCode, Cursor) and ask - which tools to enable Taskless for. -2. Show the auth tradeoff and offer to log in (skippable). -3. Show a diff against the previous install state before writing. -4. Write the canonical `taskless` skill (and `tskl` command) once to - `.taskless/`, then a thin reference stub into each selected tool - directory (`.claude/`, `.cursor/`, `.opencode/`, `.agents/`). -5. Update `.taskless/taskless.json` with the install manifest. - -The skill content lives in exactly one place, `.taskless/skills/`, -and each tool directory holds only a short stub that points at it. -Stale layouts from older installs (full per-tool copies, symlinks) -are converged into stubs automatically. If the user is on v0.6 or -earlier, the obsolete per-task skills (taskless-check, etc.) and old -slash commands are removed as part of the install; the summary shows -what was removed. +1. **Run the non-interactive install.** + ``` + %(TASKLESS_CLI)s init --no-interactive --json + ``` + The envelope: + ```json + { + "success": true, + "commandsInstalled": true, + "cliVersion": { "previous": "0.10.2", "installed": "0.11.1" }, + "targets": [ + { "dir": ".taskless", "mode": "canonical", + "writtenSkills": ["taskless"], "writtenCommands": [], + "removedSkills": [], "removedCommands": [] }, + { "dir": ".claude", "mode": "reference", + "writtenSkills": ["taskless"], "writtenCommands": ["tskl.md"], + "removedSkills": [], "removedCommands": [] } + ], + "changed": true, + "migrated": { "from": 3, "to": 4, "applied": [4], + "files": { "added": [], "modified": [], "removed": [] } } + } + ``` + - `cliVersion.previous` is `null` on a project with no recorded + install. When it differs from `installed`, the CLI was upgraded. + - `targets` lists every install location and what this run wrote or + removed there, by name. `mode: "canonical"` is the `.taskless/` + store; `reference` is a tool directory holding stubs. + - `changed` is `true` when a migration ran or any target list is + non-empty. When it is `false`, stop here: nothing to commit, + nothing to reconcile. + - `migrated` is present only when a migration ran, with the paths it + added, rewrote, or deleted. + + Without `--json`, the same facts print as prose: a per-target summary, + then a trailer naming the directories that changed and, after a + version move, pointing at `update`. + +2. **Commit what changed.** Every `targets[].dir` with a non-empty list, + plus `.taskless/` and any `migrated.files` entries, now holds changes + that belong in the working tree's next commit. Stage them with the + work you were doing, or as their own commit if the user prefers, and + say what Taskless rewrote and why (a CLI upgrade, a layout migration). + Do not leave them for whoever commits next. + +3. **After a version move, reconcile the rules.** When + `cliVersion.previous` is non-null and differs from `installed`, run + ``` + %(TASKLESS_CLI)s update + ``` + and follow it. The migration moved the DIRECTORY; the rules in it may + still need work an upgrade cannot do for them (a rewriter that now + needs a `fix`, a rule whose matching semantics shifted under a new + engine). `update` is how to find out, and the only way to record that + the walk was done. + +4. **Treat your own session as stale.** A tool loads its skill list once, + at startup. If Taskless was installed or upgraded during this session, + the skill text in your context is the previous version. Tell the user + the skills changed and that a new session, or a skill reload, picks + them up. Recipes are unaffected: every `agent ` fetch reads the + installed CLI. + +5. **Return to what sent you here.** Re-run the command that refused. + +## For a person at a terminal + +`%(TASKLESS_CLI)s` with no subcommand launches an interactive wizard. It +detects the installed tools (Claude Code, OpenCode, Cursor, Codex), asks +which to enable, offers a login (skippable), shows a diff against the +previous install, then writes the canonical `taskless` skill (and `tskl` +command) once to `.taskless/` and a thin stub into each selected tool +directory. Stale layouts from older installs converge to stubs +automatically; v0.6-era per-task skills and commands are removed and the +summary shows what went. ## Errors +- `SCAFFOLD_VERSION_MISMATCH`: the project's `.taskless/` is NEWER than + this CLI. Upgrade the CLI; do not migrate downward. +- No tools detected → the skill is written to `.agents/skills/` and no + slash command is installed. Not an error. - Wizard cancelled (Ctrl-C) → no filesystem writes. Re-run when ready. -- No tools detected → falls back to `.agents/skills/`. ## See Also +- `%(TASKLESS_CLI)s update`: what an upgrade changed for existing rules - `%(TASKLESS_CLI)s agent info`: verify what's installed and check staleness - `%(TASKLESS_CLI)s agent auth`: authenticate after installing diff --git a/packages/cli/src/agent/onboard.md b/packages/cli/src/agent/onboard.md index eff1932f..5e25a668 100644 --- a/packages/cli/src/agent/onboard.md +++ b/packages/cli/src/agent/onboard.md @@ -1,4 +1,5 @@ # Topic: onboard (CLI v%(CLI_VERSION)s / topic v3) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Help a user who has just installed Taskless go from zero rules to a diff --git a/packages/cli/src/agent/route.md b/packages/cli/src/agent/route.md index cf9ec77d..12ae1e64 100644 --- a/packages/cli/src/agent/route.md +++ b/packages/cli/src/agent/route.md @@ -1,4 +1,5 @@ # Topic: route (CLI v%(CLI_VERSION)s / topic v5) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Turn "write me a rule that…" into one command to run. This is the front diff --git a/packages/cli/src/agent/rule-meta.md b/packages/cli/src/agent/rule-meta.md index d0441bf8..8b90b8b6 100644 --- a/packages/cli/src/agent/rule-meta.md +++ b/packages/cli/src/agent/rule-meta.md @@ -1,4 +1,5 @@ # Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v3) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Report what `%(TASKLESS_CLI)s rule meta` does today, so no recipe and no diff --git a/packages/cli/src/agent/rule.md b/packages/cli/src/agent/rule.md index f42c5f3e..fd282385 100644 --- a/packages/cli/src/agent/rule.md +++ b/packages/cli/src/agent/rule.md @@ -1,4 +1,5 @@ # Topic: rule (CLI v%(CLI_VERSION)s / topic v2) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Umbrella for rule operations. Fetch the topic for the action you want. diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index f9f8ef26..cd9d580a 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,5 @@ # Topic: update (CLI v%(CLI_VERSION)s / topic v5) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `update`. It tells you what an upgrade changed for the rules diff --git a/packages/cli/src/agent/verify-rule.md b/packages/cli/src/agent/verify-rule.md index 659f5378..5127b913 100644 --- a/packages/cli/src/agent/verify-rule.md +++ b/packages/cli/src/agent/verify-rule.md @@ -1,4 +1,5 @@ # Topic: verify-rule (CLI v%(CLI_VERSION)s / topic v3) +Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Check that a rule is well-formed (`verify`), then run its tests diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index eb7f520c..af9f688a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -12,7 +12,9 @@ import { getEmbeddedSkills, } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; -import { getReloadNotice } from "../install/reload-notice"; +import type { InstallMode } from "../install/state"; +import { getReloadNotice, versionMoved } from "../install/reload-notice"; +import { getUpgradeTrailer } from "../install/upgrade-trailer"; import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { runWizard } from "../wizard"; @@ -34,6 +36,7 @@ import { readManifest } from "../filesystem/migrate"; import type { MigrationReport } from "../filesystem/migrate"; import { TASKLESS_DIRECTORY } from "../rules/vale/formats"; import { CLIError } from "../util/cli-error"; +import { buildInvocation } from "../util/invocation"; import { makeErrorEnvelope } from "../types/errors"; function shouldRunInteractively(noInteractiveFlag: boolean): boolean { @@ -100,6 +103,19 @@ export const initCommand = defineCommand({ JSON.stringify({ success: true, commandsInstalled: result.commandsInstalled, + // `null` rather than absent when nothing was recorded, so a consumer + // reads "fresh project" as a value and never has to probe for a key. + cliVersion: { + previous: result.previousCliVersion ?? null, + installed: result.cliVersion, + }, + // The per-target summary the human path prints. It used to go to + // stderr under `--json` because nothing on the envelope carried it. + targets: result.targets, + // Derivable from `migrated` and `targets`, and included anyway: it + // is the one value an agent gates its commit step on, and folding + // four lists and a presence check is how a consumer gets it wrong. + changed: result.changed, // 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. @@ -112,6 +128,20 @@ export const initCommand = defineCommand({ if (result.reloadNotice !== undefined) { console.log(result.reloadNotice); } + // Before the onboarding trailer, which stays the final line: several + // scenarios pin it there, and an agent reads all of stdout anyway. + const upgradeTrailer = getUpgradeTrailer({ + changedDirectories: result.targets + .filter((target) => targetChanged(target)) + .map((target) => target.dir), + migrated: result.migrated !== undefined, + previousCliVersion: result.previousCliVersion, + cliVersion: result.cliVersion, + invocation: buildInvocation(), + }); + if (upgradeTrailer !== undefined) { + console.log(upgradeTrailer); + } console.log( getOnboardTrailer({ commandsInstalled: result.commandsInstalled }) ); @@ -245,6 +275,25 @@ export const updateCommand = defineCommand({ }, }); +/** One install target's outcome, in the shape the `--json` envelope carries. */ +interface TargetOutcome { + dir: string; + mode: InstallMode; + writtenSkills: string[]; + writtenCommands: string[]; + removedSkills: string[]; + removedCommands: string[]; +} + +function targetChanged(target: TargetOutcome): boolean { + return ( + target.writtenSkills.length > 0 || + target.writtenCommands.length > 0 || + target.removedSkills.length > 0 || + target.removedCommands.length > 0 + ); +} + async function runNonInteractive( cwd: string, options: { json?: boolean } = {} @@ -252,6 +301,11 @@ async function runNonInteractive( commandsInstalled: boolean; reloadNotice: string | undefined; migrated: MigrationReport | undefined; + previousCliVersion: string | undefined; + cliVersion: string; + targets: TargetOutcome[]; + /** Whether a migration ran or any target wrote or removed anything. */ + changed: boolean; }> { // Under `--json`, stdout carries only the envelope printed by the caller. // This per-target summary is not on that envelope (it is finer-grained than @@ -345,19 +399,24 @@ async function runNonInteractive( })) ); + const targets: TargetOutcome[] = []; for (const target of plan.targets) { const writtenSkills = skillsByTarget.get(target.dir) ?? []; const writtenCommands = commandsByTarget.get(target.dir) ?? []; const removedSkills = removedSkillsByTarget.get(target.dir) ?? []; const removedCommands = removedCommandsByTarget.get(target.dir) ?? []; const noun = target.mode === "canonical" ? "canonical file" : "stub"; + const outcome: TargetOutcome = { + dir: target.dir, + mode: target.mode, + writtenSkills, + writtenCommands, + removedSkills, + removedCommands, + }; + targets.push(outcome); - if ( - writtenSkills.length === 0 && - writtenCommands.length === 0 && - removedSkills.length === 0 && - removedCommands.length === 0 - ) { + if (!targetChanged(outcome)) { log(`${target.label} (${target.dir}/): up to date`); continue; } @@ -385,7 +444,20 @@ async function runNonInteractive( } } - return { commandsInstalled, reloadNotice, migrated }; + return { + commandsInstalled, + reloadNotice, + migrated, + previousCliVersion, + cliVersion, + targets, + // 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)) || + versionMoved({ previousCliVersion, cliVersion }), + }; } function groupValuesByTarget( diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index eb0cd663..bc74a5f5 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -426,11 +426,16 @@ export async function requireCurrentSchema(cwd: string): Promise { const pending = await pendingMigration(cwd); if (pending === undefined) return; + // The same test `init` itself applies before choosing wizard or batch. An + // agent follows this message verbatim, and a bare `init` under a pipe would + // print the topic index and change nothing. + const initCommand = + process.stdout.isTTY === true ? "init" : "init --no-interactive"; throw new CLIError( `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 \`${buildInvocation()} ${initCommand}\` to migrate, then run this again.`, "SCAFFOLD_MIGRATION_REQUIRED" ); } diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 731a2699..b9243be8 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { stringify } from "yaml"; @@ -58,16 +58,38 @@ export async function writeCanonicalSkill( cwd: string, name: string, content: string -): Promise { +): Promise { const directory = join(cwd, CANONICAL_DIR, "skills", name); await mkdir(directory, { recursive: true }); const path = join(directory, "SKILL.md"); - await writeFile( + const changed = await writeIfChanged( path, - withCliBuildNotice(applyCliInvocation(content)), - "utf8" + withCliBuildNotice(applyCliInvocation(content)) ); - return path; + return { path, changed }; +} + +/** Where a canonical file landed, and whether the write changed its bytes. */ +export interface CanonicalWrite { + path: string; + changed: boolean; +} + +/** + * Write only when the bytes differ. The canonical store used to be rewritten + * on every install, which made every run report a write there and left an + * agent unable to tell a no-op re-install from an upgrade: the install + * summary said "wrote 1 skill canonical file(s)" either way, and the upgrade + * trailer would have told it to commit a file git saw no change in. + */ +async function writeIfChanged(path: string, content: string): Promise { + try { + if ((await readFile(path, "utf8")) === content) return false; + } catch { + // Absent or unreadable: write it. + } + await writeFile(path, content, "utf8"); + return true; } /** @@ -80,16 +102,15 @@ export async function writeCanonicalCommand( cwd: string, filename: string, content: string -): Promise { +): Promise { const directory = join(cwd, CANONICAL_DIR, "commands", "tskl"); await mkdir(directory, { recursive: true }); const path = join(directory, filename); - await writeFile( + const changed = await writeIfChanged( path, - withCliBuildNotice(applyCliInvocation(content)), - "utf8" + withCliBuildNotice(applyCliInvocation(content)) ); - return path; + return { path, changed }; } /** diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 530591ea..c2714dad 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -465,8 +465,12 @@ async function writeSkill( skill: EmbeddedSkill ): Promise { if (target.mode === "canonical") { - await writeCanonicalSkill(cwd, skill.name, skill.content); - return true; + const { changed } = await writeCanonicalSkill( + cwd, + skill.name, + skill.content + ); + return changed; } const path = join(skillDirectory(cwd, target.dir, skill.name), "SKILL.md"); @@ -496,8 +500,12 @@ async function writeCommand( command: EmbeddedCommand ): Promise { if (target.mode === "canonical") { - await writeCanonicalCommand(cwd, command.filename, command.content); - return true; + const { changed } = await writeCanonicalCommand( + cwd, + command.filename, + command.content + ); + return changed; } const path = commandFile(cwd, target.dir, command.filename); diff --git a/packages/cli/src/install/reload-notice.ts b/packages/cli/src/install/reload-notice.ts index 877b49fb..046dd2f6 100644 --- a/packages/cli/src/install/reload-notice.ts +++ b/packages/cli/src/install/reload-notice.ts @@ -81,7 +81,7 @@ function wrap(text: string, columns: number): string[] { * including a downgrade and including a stable/nightly swap, since both leave * the same stale copy in memory. */ -function versionMoved(input: ReloadNoticeInput): boolean { +export function versionMoved(input: ReloadNoticeInput): boolean { return ( input.previousCliVersion !== undefined && input.previousCliVersion !== input.cliVersion diff --git a/packages/cli/src/install/upgrade-trailer.ts b/packages/cli/src/install/upgrade-trailer.ts new file mode 100644 index 00000000..ba607b68 --- /dev/null +++ b/packages/cli/src/install/upgrade-trailer.ts @@ -0,0 +1,58 @@ +import { versionMoved } from "./reload-notice"; + +/** + * What a non-interactive install changed, in the terms an agent acts on. + * + * The install summary above this trailer says what was written. It does not + * say that the writes landed in files under version control, or that a move + * between two recorded CLI versions is the moment `update` exists for. An + * agent that `check` sent to `init` reads the summary, sees success, and goes + * back to `check`; the rewritten stubs are left for whoever commits next, and + * the ledger walk never happens. This trailer names both obligations. + */ +export interface UpgradeTrailerInput { + /** Target directories with at least one write or removal this run. */ + changedDirectories: string[]; + /** Whether a scaffold migration ran. */ + migrated: boolean; + /** The `install.cliVersion` recorded before this run, if there was one. */ + previousCliVersion?: string; + /** The version this run recorded. */ + cliVersion: string; + /** The invocation to print in front of `update`, e.g. `npx @taskless/cli`. */ + invocation: string; +} + +/** + * The trailer, or `undefined` when this run changed nothing. A no-op + * re-install has nothing to commit and nothing to reconcile, and printing a + * trailer that says so would teach an agent to skim it. + */ +export function getUpgradeTrailer( + input: UpgradeTrailerInput +): string | undefined { + // `.taskless/` is also the canonical install target, so it is usually in the + // list already. The set keeps a migration-only run from listing it twice. A + // version move counts on its own: it rewrites `install.cliVersion` in + // `.taskless/taskless.json`, which is a tracked file, even when no skill + // bytes changed. + const directories = new Set(input.changedDirectories); + if (input.migrated || versionMoved(input)) directories.add(".taskless"); + if (directories.size === 0) return undefined; + + const listed = [...directories] + .map((directory) => `${directory.replace(/\/$/, "")}/`) + .join(", "); + const lines = [ + `Taskless changed files under ${listed}. They belong in your next commit.`, + ]; + + if (versionMoved(input)) { + lines.push( + `The CLI moved from ${input.previousCliVersion ?? ""} to ${input.cliVersion}. ` + + `Run \`${input.invocation} update\` to learn what that changes for the rules already in this project.` + ); + } + + return lines.join("\n"); +} diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 8e2e6ae9..88a198b7 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -328,9 +328,15 @@ const HEADER_PREFIX = "# Topic:"; /** * Drop the leading header block from rendered recipe text: the `# Topic: …` - * line itself plus the single blank line that separates it from the body. - * Everything after that is returned untouched, so the body of a header-less - * rendering is byte-identical to the default rendering's body. + * line, the fetch-time directive beneath it, and the single blank line that + * separates the block from the body. Everything after that is returned + * untouched, so the body of a header-less rendering is byte-identical to the + * default rendering's body. + * + * The block is "everything up to the first blank line" rather than a fixed + * line count, so the directive travels with the version line: a consumer + * that suppresses the header wants a cache-stable prompt to embed in its own, + * and an instruction to re-run a CLI is as wrong there as a version string. * * Deliberately anchored to the first line only. A `# Topic:` string later in * a recipe (inside a fenced example, say) is left alone, and a recipe that @@ -338,13 +344,10 @@ const HEADER_PREFIX = "# Topic:"; * first real line. */ function stripHeader(content: string): string { - const firstBreak = content.indexOf("\n"); - if (firstBreak === -1) { - return content.startsWith(HEADER_PREFIX) ? "" : content; - } if (!content.startsWith(HEADER_PREFIX)) return content; - const body = content.slice(firstBreak + 1); - return body.startsWith("\n") ? body.slice(1) : body; + const blockEnd = content.indexOf("\n\n"); + if (blockEnd === -1) return ""; + return content.slice(blockEnd + 2); } /** diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index a838e740..0056f2ef 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -108,7 +108,7 @@ describe("applyInstallPlan", () => { ).toBe(true); }); - it("rewrites the canonical store but skips an unchanged reference stub", async () => { + it("reports neither an unchanged canonical file nor an unchanged reference stub", async () => { const plan = buildInstallPlan( [".claude"], [tasklessSkill()], @@ -117,16 +117,28 @@ describe("applyInstallPlan", () => { await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const second = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - // Canonical is always rewritten; the unchanged .claude stub is skipped. + // The canonical store used to be rewritten on every run and reported as + // such, so a no-op re-install read as an upgrade. Identical bytes are + // not a write, in either mode. + expect(second.writtenSkills).toHaveLength(0); + expect(second.writtenCommands).toHaveLength(0); + expect(second.removedSkills).toHaveLength(0); + }); + + it("reports the canonical file when its bytes changed", async () => { + const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); + await writeFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "stale\n", + "utf8" + ); + + const second = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); expect(second.writtenSkills).toContainEqual({ target: ".taskless", skill: "taskless", }); - expect(second.writtenSkills).not.toContainEqual({ - target: ".claude", - skill: "taskless", - }); - expect(second.removedSkills).toHaveLength(0); }); it("does not clobber an existing reference stub on re-run", async () => { diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index 3594a247..fd8a3a77 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -44,11 +44,12 @@ describe("writeCanonicalSkill / writeCanonicalCommand", () => { }); it("writes skill content to .taskless/skills verbatim", async () => { - const path = await writeCanonicalSkill( + const { path, changed } = await writeCanonicalSkill( temporaryDirectory, "taskless", skillSource ); + expect(changed).toBe(true); expect(path).toBe( join(temporaryDirectory, ".taskless", "skills", "taskless", "SKILL.md") ); @@ -57,16 +58,36 @@ describe("writeCanonicalSkill / writeCanonicalCommand", () => { it("writes command content to .taskless/commands/tskl verbatim", async () => { const commandSource = "---\nname: Taskless\n---\n\nbody\n"; - const path = await writeCanonicalCommand( + const { path, changed } = await writeCanonicalCommand( temporaryDirectory, "tskl.md", commandSource ); + expect(changed).toBe(true); expect(path).toBe( join(temporaryDirectory, ".taskless", "commands", "tskl", "tskl.md") ); expect(await readFile(path, "utf8")).toBe(commandSource); }); + + it("reports no change when the bytes on disk already match", async () => { + // Every install used to rewrite the store, so every run looked like an + // upgrade to whoever read the summary. Identical bytes are not a write. + await writeCanonicalSkill(temporaryDirectory, "taskless", skillSource); + const second = await writeCanonicalSkill( + temporaryDirectory, + "taskless", + skillSource + ); + expect(second.changed).toBe(false); + + const third = await writeCanonicalSkill( + temporaryDirectory, + "taskless", + `${skillSource}\nmore\n` + ); + expect(third.changed).toBe(true); + }); }); describe("buildSkillStub", () => { diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index dbad27a6..4fd36677 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -196,6 +196,89 @@ describe("taskless init --no-interactive", () => { expect(stdout).toMatch(/`taskless onboard`/); }); + it("prints an upgrade trailer naming the changed directories, before the onboarding trailer", async () => { + // An agent that `check` sent here reads success and goes back to `check`. + // The trailer is what tells it the stubs it just rewrote belong in its + // commit. The onboarding trailer stays the final line: several scenarios + // pin it there, and an agent reads all of stdout anyway. + await mkdir(join(cwd, ".claude"), { recursive: true }); + await installAtVersion(cwd, "0.0.1-previous"); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + // Forcing a rewrite: the recorded version moved, so the canonical store + // and every stub are written again. + expect(stdout).toContain("belong in your next commit"); + expect(stdout).toContain(".taskless/"); + expect(stdout).toContain(".claude/"); + expect(stdout).toContain("moved from 0.0.1-previous to"); + expect(stdout).toMatch(/Run `.* update`/); + + const lines = stdout.trimEnd().split("\n"); + expect(lines.at(-1)).toMatch(/^Next:/); + expect( + lines.indexOf(lines.find((line) => line.includes("next commit"))!) + ).toBeLessThan(lines.length - 1); + }); + + it("omits the update pointer when the version did not move", async () => { + // A change without an upgrade is still something to commit, but there is + // no ledger to walk: `update` would report nothing. + await installAtVersion(cwd, "0.0.1-previous"); + // Rewrite at the previous version so the next run sees no move but has + // to re-write the stubs it finds stale. + await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + await mkdir(join(cwd, ".claude"), { recursive: true }); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).toContain("belong in your next commit"); + expect(stdout).toContain(".claude/"); + expect(stdout).not.toContain("moved from"); + expect(stdout).not.toMatch(/Run `.* update`/); + }); + + it("prints no upgrade trailer when a re-install changed nothing", async () => { + // A no-op has nothing to commit and nothing to reconcile. A trailer that + // said so would teach an agent to skim it. + await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).not.toContain("next commit"); + expect(stdout).not.toContain("moved from"); + }); + it("`taskless update` does NOT print the onboarding trailer", async () => { // Update is the same install plumbing but the trailer is scoped to init. await mkdir(join(cwd, ".claude"), { recursive: true }); diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 2ad490b4..4330be9a 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -113,6 +113,28 @@ describe("this repository's own installed Taskless docs", () => { expect(onDisk).not.toContain("sg/rules/"); }); + it("tells an agent a recipe is per-task, in the skill and the command", async () => { + // A recipe is resolved at fetch time. Without this an agent that fetched + // `agent check` once reuses the text for every later task in the session, + // including after the upgrade that changed it. Both always-loaded surfaces + // carry it, since a tool may install one without the other. + const skill = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + const command = await readFile( + resolve(repositoryRoot, "commands", "tskl", "tskl.md"), + "utf8" + ); + for (const [name, body] of [ + ["SKILL.md", skill], + ["tskl.md", command], + ] as const) { + expect(body, name).toContain("resolved when it is fetched"); + expect(body, name).toContain("earlier in this session"); + } + }); + it("keeps the skill's trigger text off the old layout too", async () => { // The skill description is what an agent reads before it opens anything, so // a stale directory name here sends it looking in a place that no longer diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index ae026e5d..dac49776 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -149,6 +149,75 @@ describe("who migrates, and who refuses", () => { expect(stderr).not.toContain("Migrat"); }); + it("init --json carries the version, the per-target summary, and a changed flag", async () => { + // The facts the human trailer states, as fields. `changed` is derivable + // from the rest and included anyway: it is the one value an agent gates + // its commit step on. + await seedVersion3(); + await mkdir(join(temporaryDirectory, ".claude"), { recursive: true }); + + const { stdout } = await runCli([ + "init", + "--no-interactive", + "--json", + "-d", + temporaryDirectory, + ]); + + const envelope = parseEnvelope(stdout) as { + cliVersion: { previous: string | null; installed: string }; + targets: Array<{ + dir: string; + mode: string; + writtenSkills: string[]; + writtenCommands: string[]; + removedSkills: string[]; + removedCommands: string[]; + }>; + changed: boolean; + migrated?: unknown; + }; + + // A seeded project has no recorded install, and "none" is a value. + expect(envelope.cliVersion.previous).toBeNull(); + expect(envelope.cliVersion.installed).toMatch(/\d+\.\d+\.\d+/); + + const byDirectory = new Map(envelope.targets.map((t) => [t.dir, t])); + expect(byDirectory.get(".taskless")?.mode).toBe("canonical"); + expect(byDirectory.get(".taskless")?.writtenSkills).toContain("taskless"); + expect(byDirectory.get(".claude")?.mode).toBe("reference"); + expect(byDirectory.get(".claude")?.writtenCommands).toContain("tskl.md"); + + expect(envelope.migrated).toBeDefined(); + expect(envelope.changed).toBe(true); + }); + + it("init --json reports changed:false, a recorded version, and empty lists on a no-op re-install", async () => { + await seedVersion3(); + await runCli(["init", "--no-interactive", "-d", temporaryDirectory]); + + const { stdout } = await runCli([ + "init", + "--no-interactive", + "--json", + "-d", + temporaryDirectory, + ]); + + const envelope = parseEnvelope(stdout) as { + cliVersion: { previous: string | null; installed: string }; + targets: Array<{ writtenSkills: string[]; removedSkills: string[] }>; + changed: boolean; + }; + expect(envelope.cliVersion.previous).toBe(envelope.cliVersion.installed); + expect(envelope.changed).toBe(false); + expect(envelope).not.toHaveProperty("migrated"); + for (const target of envelope.targets) { + expect(target.writtenSkills).toEqual([]); + expect(target.removedSkills).toEqual([]); + } + }); + it("init --json omits the field when nothing migrated", async () => { // Absence is the signal, so a consumer never reads empty arrays to decide. await seedVersion3(); diff --git a/packages/cli/test/no-implicit-migration.test.ts b/packages/cli/test/no-implicit-migration.test.ts index 0cf04bb2..c247eb6f 100644 --- a/packages/cli/test/no-implicit-migration.test.ts +++ b/packages/cli/test/no-implicit-migration.test.ts @@ -211,6 +211,14 @@ describe("a reporting command never migrates", () => { } }); + it("points a piped caller at init --no-interactive", async () => { + // Every run in this suite has a piped stdout, which is what an agent + // has. A bare `init` there prints the topic index and migrates nothing, + // so a refusal naming it would send the agent in a loop. + const { stderr } = await runCli(["check", "-d", directory]); + expect(stderr).toMatch(/Run `.* init --no-interactive` to migrate/); + }); + it("still explains the scaffold migration to a person", async () => { // Suppressed for machines, not removed. Without `--json` the summary is // the only thing telling someone their working tree just changed. diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 941f0fdf..c07ea17f 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { readFile, readdir } from "node:fs/promises"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -76,6 +76,28 @@ describe("prompt rendering", () => { expect(getPrompt("create-sg-rule")).toContain(`CLI v${__VERSION__}`); }); + it("carries the fetch-time directive as line 2 of every recipe on disk", async () => { + // Byte-identical across recipes, including the `.anonymous` variants, so + // an agent sees one sentence rather than twenty paraphrases of it. Line 3 + // is blank because `stripHeader` ends the block at the first blank line: + // a recipe that ran the directive into its body would keep it in the + // header-less rendering. + const allEntries = await readdir(recipeDirectory); + const entries = allEntries.filter((name) => name.endsWith(".md")); + const directives = new Set(); + for (const name of entries) { + const content = await readFile(join(recipeDirectory, name), "utf8"); + const lines = content.split("\n"); + expect(lines[0], `${name} line 1`).toMatch(/^# Topic:/); + expect(lines[1], `${name} line 2`).toContain("do not reuse this copy"); + expect(lines[1], `${name} line 2`).toContain("%(TASKLESS_CLI)s agent"); + expect(lines[2], `${name} line 3`).toBe(""); + directives.add(lines[1]!); + } + expect(entries.length).toBeGreaterThan(0); + expect([...directives]).toHaveLength(1); + }); + it.each([ ["create-remote-rule", "prompt"], ["improve-rule", "ruleId"], @@ -349,14 +371,26 @@ describe("host mechanics suppression", () => { }); describe("header suppression", () => { - it("drops the header line and the blank line after it, leaving the body intact", () => { + it("drops the header block and the blank line after it, leaving the body intact", () => { const withHeader = getPrompt("create-sg-rule"); const withoutHeader = getPrompt("create-sg-rule", { header: false }); expect(withHeader.startsWith("# Topic: create-sg-rule")).toBe(true); expect(withoutHeader.startsWith("# Topic:")).toBe(false); - // The body is the same string, minus the header line and its blank line. - expect(withoutHeader).toBe(withHeader.split("\n").slice(2).join("\n")); + // The block is the topic line, the fetch-time directive, and the blank + // line that closes it. The body is the same string minus those three. + expect(withoutHeader).toBe(withHeader.split("\n").slice(3).join("\n")); + }); + + it("drops the fetch-time directive with the version", () => { + // A header-less rendering is for a consumer embedding the text in its + // own prompt. "Re-run the CLI" is as wrong there as a version string. + for (const topic of TOPICS) { + expect( + getPrompt(topic, { header: false }), + `${topic} kept the directive` + ).not.toContain("do not reuse this copy"); + } }); it("leaves no CLI version string behind", () => { diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 3e9e5a03..308f464f 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -83,6 +83,14 @@ If the user's intent is ambiguous between two topics, run `npx @taskless/cli agent` (no args) to see the disambiguation table, or ask the user. +## Fetch the recipe for every task + +A recipe is resolved when it is fetched: the CLI reads the installed version, +the auth state, and the project layout at that moment. A recipe you fetched +earlier in this session is not a substitute for fetching it again, even for +the same topic. Start each Taskless task with `npx @taskless/cli agent ` +and follow the copy it returns. + ## --anonymous Any rule/check command accepts `--anonymous` to skip the Taskless API and From 5e704000d00d28ab064b5464296634a0b5b09e65 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 10 Sep 2026 16:42:51 -0700 Subject: [PATCH 2/4] fix(cli): address self-review on init-upgrade-context - `init` is the batch install in every context; `--no-interactive` is dropped and a bare TTY invocation calls the wizard directly. - The fetch-time directive is rendered by the `agent`, `update`, and `onboard` commands rather than written into recipe files, and now also says a session that installed or upgraded holds a stale skill. The prompts export does not carry it. - The upgrade trailer prints directly after the summary, before the reload banner. - The init recipe tells the agent to name what needs committing rather than to stage it. - The TTY-dependent refusal wording is reverted: `init` is the answer everywhere. Re-archives the change with the revised deltas. --- .changeset/init-upgrade-context.md | 2 +- .../2026-09-10-init-upgrade-context/design.md | 37 ++++---- .../proposal.md | 27 +++--- .../specs/cli-agent/spec.md | 41 ++++----- .../specs/cli-init/spec.md | 71 +++++++++++---- .../specs/cli-knowledge-prompts/spec.md | 21 ++--- .../2026-09-10-init-upgrade-context/tasks.md | 36 ++++---- openspec/specs/cli-agent/spec.md | 39 ++++++--- openspec/specs/cli-init/spec.md | 48 +++++----- openspec/specs/cli-knowledge-prompts/spec.md | 11 +-- packages/cli/README.md | 5 +- packages/cli/src/agent/auth.md | 1 - packages/cli/src/agent/check.md | 1 - packages/cli/src/agent/ci.md | 1 - packages/cli/src/agent/create-legacy-rule.md | 1 - packages/cli/src/agent/create-remote-rule.md | 1 - packages/cli/src/agent/create-runtime-rule.md | 1 - packages/cli/src/agent/create-sg-rule.md | 1 - packages/cli/src/agent/create-vale-rule.md | 1 - packages/cli/src/agent/delete-rule.md | 1 - packages/cli/src/agent/detect.md | 1 - .../cli/src/agent/improve-rule.anonymous.md | 1 - packages/cli/src/agent/improve-rule.md | 1 - packages/cli/src/agent/info.md | 1 - packages/cli/src/agent/init.md | 27 +++--- packages/cli/src/agent/onboard.md | 1 - packages/cli/src/agent/route.md | 1 - packages/cli/src/agent/rule-meta.md | 1 - packages/cli/src/agent/rule.md | 1 - packages/cli/src/agent/update.md | 1 - packages/cli/src/agent/verify-rule.md | 1 - packages/cli/src/commands/agent.ts | 5 ++ packages/cli/src/commands/init.ts | 55 ++++-------- packages/cli/src/commands/onboard.ts | 3 + packages/cli/src/filesystem/migrate.ts | 7 +- packages/cli/src/index.ts | 12 ++- packages/cli/src/prompts/recipes.ts | 45 +++++++++- packages/cli/test/agent-extensions.test.ts | 15 ++++ packages/cli/test/anonymous-flag.test.ts | 8 +- packages/cli/test/init-no-interactive.test.ts | 87 ++++++++----------- .../cli/test/installed-documentation.test.ts | 2 +- packages/cli/test/migrated-envelope.test.ts | 25 ++---- packages/cli/test/mixed-engine-check.test.ts | 6 +- .../cli/test/no-implicit-migration.test.ts | 33 +------ packages/cli/test/prompts.test.ts | 79 +++++++++++------ packages/cli/test/reconcile-marker.test.ts | 4 +- .../cli/test/runtime-fixture-runner.test.ts | 2 +- packages/cli/test/schemas-export.test.ts | 2 +- .../cli/test/verify-test-commands.test.ts | 2 +- packages/cli/test/verify.test.ts | 2 +- packages/cli/test/wizard-integration.test.ts | 2 +- 51 files changed, 411 insertions(+), 369 deletions(-) diff --git a/.changeset/init-upgrade-context.md b/.changeset/init-upgrade-context.md index 502c3cd7..4dfe0fb4 100644 --- a/.changeset/init-upgrade-context.md +++ b/.changeset/init-upgrade-context.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -`taskless init --no-interactive` now tells an agent what an install changed and what follows. The human output gains an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical file whose bytes already match the bundle is no longer rewritten or reported as written. The `SCAFFOLD_MIGRATION_REQUIRED` refusal names `init --no-interactive` when stdout is not a TTY. The `init` recipe is rewritten for the agent that runs it. Every recipe carries a fetch-time directive on its second line, stripped along with the version by `header: false`, and the skill and `tskl` command say a recipe is fetched again for each task. +`taskless init` is now the batch install in every context, and `--no-interactive` is dropped: a bare `npx @taskless/cli` in a terminal is the wizard, `init` is the install and upgrade path for agents, scripts, and CI, and `agent init` is the recipe. A script that still passes the flag gets `init` unchanged. `init` ends with an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written. The `agent` subcommand serves every recipe under a fetch-time directive (fetch again next task; a session that installed or upgraded Taskless holds a stale skill), which the `@taskless/cli/prompts` export does not carry and which `header: false` strips with the version. The `agent init` recipe is rewritten for the agent that runs it, and the skill and `tskl` command say a recipe is fetched again for each task. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md index 2bff7de1..c8aac1cc 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md @@ -1,47 +1,52 @@ ## Context -`taskless init --no-interactive` is the install path an agent takes. It ends with a per-target summary, a migration notice on stderr, a reload banner when the version moved, and the onboarding trailer. Under `--json` it prints `{ success, commandsInstalled, migrated? }`. The `init` recipe addresses a human. Recipes open with a one-line `# Topic:` header that `PromptOptions.header: false` strips. The skill body tells an agent to fetch a recipe before acting but says nothing about fetching it again. +`taskless init` is the install path an agent takes, and until now the same word also launched the wizard when a TTY happened to be attached, with `--no-interactive` to force the batch path. The batch path ends with a per-target summary, a migration notice on stderr, a reload banner when the version moved, and the onboarding trailer. Under `--json` it prints `{ success, commandsInstalled, migrated? }`. The `agent init` recipe addresses a human. Recipes open with a one-line `# Topic:` header that `PromptOptions.header: false` strips. The skill body tells an agent to fetch a recipe before acting but says nothing about fetching it again. ## Goals / Non-Goals **Goals** +- One spelling per reader: bare invocation for a person (wizard), `init` for an agent or script (batch, always), `agent init` for the recipe that explains the second to the first. - An agent that ran `init` can tell, from stdout or the envelope alone, that files changed, where, and whether `update` is the next step. -- The `init` recipe reads as instructions for the agent that runs the command. -- A recipe and the skill both say the recipe is not reusable across tasks. +- The `agent init` recipe reads as instructions for the agent that runs the command. +- Everything the `agent` subcommand serves says it is not reusable across tasks; the skill says the same. **Non-Goals** -- Committing on the agent's behalf. `init` reports; it does not run git. -- Reworking the onboarding trailer or the reload banner. Both keep their position and wording. +- Committing on the agent's behalf. `init` reports, and the recipe tells the agent to tell the user; nobody runs git. +- Reworking the reload banner or the onboarding trailer's wording. - Changing the interactive wizard's output. Its reader is a person watching a terminal. - A general "session state" mechanism for recipes. The directive is prose. ## Decisions -### The upgrade trailer sits before the onboarding trailer +### `init` is always the batch install, and `--no-interactive` is gone -The onboarding-trailer requirement makes that trailer the final line of output, and several tests pin it. The upgrade trailer prints after the install summary and reload banner, and before the onboarding line. An agent reads all of stdout, so ordering costs nothing; keeping the existing requirement intact avoids retesting six scenarios for a cosmetic reorder. +The flag existed so that a TTY could still get the batch path. With the wizard reachable only from a bare invocation, `init` has one behaviour in every context and the flag has nothing left to select. It is removed from the command definition rather than kept as a documented no-op: citty passes an undefined flag through, so a script that still spells it out gets `init` unchanged, and there is no second flag to explain. The bare TTY invocation in `index.ts` calls `runWizard` directly instead of delegating to `initCommand`. + +### The upgrade trailer prints first, directly after the summary + +A reload, and anything the onboarding line proposes, come after the upgrade is understood: a reload is required before onboarding can use the new skill, and the commit obligation exists whether or not the user onboards. So the trailer is the first of the trailing notices, before the reload banner. The onboarding trailer stays the final line, which the existing requirement and its six scenarios pin. ### Changed directories are named, not files -The install result reports skill and command names per target, not paths, and deriving paths would re-implement the layout the install module already knows. The trailer names target directories (`.claude/`, `.taskless/`) plus `.taskless/` when a migration ran, which is what a `git add` needs. Migration file paths are already listed in the migration notice, and on the envelope under `migrated.files`. +The install result reports skill and command names per target, not paths, and deriving paths would re-implement the layout the install module already knows. The trailer names target directories (`.claude/`, `.taskless/`) plus `.taskless/` when a migration ran or the version moved, which is what a `git add` needs. Migration file paths are already listed in the migration notice, and on the envelope under `migrated.files`. ### The envelope mirrors the human summary rather than a new shape -`targets` on the envelope is the per-target summary with the same fields the human path prints, which closes the gap the existing code comment describes ("this per-target summary is not on that envelope, so rather than drop it, it goes to stderr"). `cliVersion.previous` is `null` rather than absent when nothing was recorded, so a consumer can distinguish "fresh project" by value. `changed` is derivable from the other fields and is included anyway: it is the single value an agent gates its commit step on, and asking each consumer to fold three lists and a presence check is how one of them gets it wrong. +`targets` on the envelope is the per-target summary with the same fields the human path prints, which closes the gap the existing code comment describes ("this per-target summary is not on that envelope, so rather than drop it, it goes to stderr"). `cliVersion.previous` is `null` rather than absent when nothing was recorded, so a consumer can distinguish "fresh project" by value. `changed` is derivable from the other fields and is included anyway: it is the single value an agent gates its next step on, and asking each consumer to fold three lists, a presence check, and a version comparison is how one of them gets it wrong. -### The version-moved condition is the reload banner's +### A version move is a change; an identical canonical write is not -`getReloadNotice` already answers "did this run move the recorded version" with `previousCliVersion !== undefined && previousCliVersion !== cliVersion`. The `update` pointer uses the same test, so the banner and the pointer never disagree about whether an upgrade happened. +The version-moved test is the reload banner's (`previousCliVersion !== undefined && previousCliVersion !== cliVersion`), so the banner and the `update` pointer never disagree. A move rewrites `install.cliVersion` in `taskless.json`, a tracked file, so it counts even when no skill byte changed. -### The refusal picks its wording from `stdout.isTTY` +The opposite case needed fixing too. The canonical store was rewritten on every install and reported as written every time, so a no-op re-install looked like an upgrade in the summary and would have fired the trailer. `writeCanonicalSkill`/`writeCanonicalCommand` now compare bytes and return `{ path, changed }`. -`requireCurrentSchema` has no argument for interactivity and adding one to every reader would thread a flag through `check`, `verify`, and `test` for a single string. Reading `process.stdout.isTTY` in the message builder is the same test `init` itself uses to decide between wizard and batch, so the refusal and the command it points at agree. +### The directive is rendered on the `agent` command's request, not written into recipe files -### The directive is the header block's second line +Twenty recipe files carrying an identical line invited twenty paraphrases, and the line would be false for a consumer reading a recipe through `@taskless/cli/prompts`, which has no CLI to re-run. So `RecipeOptions.directive` (default `false`) inserts it as line 2 of the header block, `commands/agent.ts` and the `update` command pass `true`, and the prompts export never does. The invocation in the directive comes from the same `resolveInvocation` that renders `%(TASKLESS_CLI)s`, so the command an agent is told to re-run is the one that served it. It also says a session that installed or upgraded Taskless holds a stale skill, since the recipe is the one text such a session receives fresh. -Putting the directive in the header rather than the body keeps two properties: a header-less rendering (the cache-stable prompt) does not carry an instruction to run a CLI the consumer may not have, and the directive is dropped and added by one function. `stripHeader` changes from "first line plus one blank" to "through the first blank line", with the same first-line anchor so a `# Topic:` inside a fenced example is still untouched. The `.txt` spelling in the format requirement was stale (the files are `.md`); the MODIFIED block corrects it. +Living in the header block means `stripHeader` drops it with the version: the function now strips through the first blank line rather than a fixed line count, with the same first-line `# Topic:` anchor so a header inside a fenced example is left alone. Parity between the export and the command is stated modulo that option, and a consumer that passes `directive: true` gets exactly what the command prints. ### The skill directive is a section, budgeted against the cap @@ -49,6 +54,6 @@ The body is 72 lines against an 80-line cap. The directive is a heading and five ## Risks / Trade-offs +- **`taskless init` in a TTY no longer prompts.** That is the one behaviour a person can notice, and it is the point: `init` means the same thing everywhere. The README and the non-TTY preamble both name the bare invocation for the wizard. - **Tests that pin `init --json` to an exact object** fail on the new fields. Those are updated to assert the fields they care about, and the new fields get their own assertions. No consumer parses `init --json` outside this repository's tests today. -- **Every recipe file changes by one line**, which bumps nothing (the directive is not a meaningful recipe change) but shows up as 20 one-line diffs. The topic versions stay put except `init`, whose content changed. - **A recipe rendered with `header: false` loses the directive.** That is the intent: that rendering is for a consumer embedding the text in its own prompt, where "re-run the CLI" is the wrong instruction. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md index 0cea4730..10b3afd7 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md @@ -1,18 +1,19 @@ ## Why -An agent that runs `taskless init --no-interactive`, usually because `check` refused a project whose scaffold is behind the CLI, is told what was written and then pointed at onboarding. Nothing tells it that the rewrite touched files under version control and belongs in its commit, or that an upgrade of the CLI is the moment `taskless update` exists for. Separately, nothing in the skill or in a recipe says a recipe is resolved at fetch time, so an agent that fetched `agent check` once in a session reuses that text for every later task in the same session, including after the very upgrade that changed it. +An agent that runs `taskless init`, usually because `check` refused a project whose scaffold is behind the CLI, is told what was written and then pointed at onboarding. Nothing tells it that the rewrite touched files under version control and belongs in its commit, or that an upgrade of the CLI is the moment `taskless update` exists for. Separately, nothing in the skill or in a recipe says a recipe is resolved at fetch time, so an agent that fetched `agent check` once in a session reuses that text for every later task in the same session, including after the very upgrade that changed it. Both gaps are in agent-facing text and output shape that ship in the bundle, so they land together as one change. ## What Changes -- `taskless init --no-interactive` reports an upgrade's consequences, not only its writes. On the human path, after the install summary and before the onboarding trailer, it prints an upgrade trailer whenever the run changed anything: which directories now hold changed files and that they belong in the next commit, and, when the recorded CLI version moved, that `taskless update` reports what the upgrade means for existing rules. Under `--json`, the envelope carries the same facts as fields: `cliVersion: { previous, installed }`, a per-target `targets` list of what was written and removed (the summary that today goes only to stderr), and a `changed` boolean. -- The `init` recipe is rewritten for its actual reader. Today it says "the user runs this themselves"; its most common caller is an agent that `check` just sent there. It describes the non-interactive path, the envelope, and the three things that follow an install: commit the rewritten files, run `update` after a version move, and treat a session that predates the install as holding stale skills. -- The `SCAFFOLD_MIGRATION_REQUIRED` refusal names `init --no-interactive` for a non-TTY caller, so an agent does not reach for the wizard. -- Recipes state that they are not reusable across tasks. Every recipe's header block gains a second line saying the text was resolved at fetch time and that the next task fetches it again. `PromptOptions.header: false` strips the whole header block, so a cache-stable prompt is unchanged in shape. +- `taskless init` is the batch install in every context, and `--no-interactive` is dropped. Three spellings, three readers: `npx @taskless/cli` is the wizard for a person in a terminal; `npx @taskless/cli init` is the install and upgrade path for an agent, a script, and CI, with no flag; `npx @taskless/cli agent init` is the recipe that tells an agent how to do it. A script that still passes `--no-interactive` gets `init` unchanged, since citty passes an undefined flag through. +- `taskless init` reports an upgrade's consequences, not only its writes. On the human path, directly after the install summary and before the reload banner and the onboarding trailer, it prints an upgrade trailer whenever the run changed anything: which directories now hold changed files and that they belong in the next commit, and, when the recorded CLI version moved, that `taskless update` reports what the upgrade means for existing rules. Under `--json`, the envelope carries the same facts as fields: `cliVersion: { previous, installed }`, a per-target `targets` list of what was written and removed (the summary that today goes only to stderr), and a `changed` boolean. +- A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written, so a no-op re-install reads as one. +- The `agent init` recipe is rewritten for its actual reader. Today it says "the user runs this themselves"; its most common caller is an agent that `check` just sent there. It describes `init`, the envelope, and what follows an install: tell the user which paths need committing (the agent does not run git), run `update` after a version move, and treat a session that predates the install as holding stale skills. +- The `agent` subcommand serves every recipe under a fetch-time directive, added by the renderer as the header block's second line: the text was resolved at fetch time, the next task fetches it again, and a session that installed or upgraded Taskless holds a stale skill until reloaded. The recipe files do not carry it, and neither does the `@taskless/cli/prompts` export, whose consumer has no CLI to re-run. `header: false` strips the block whole. - The skill body and the `tskl` command carry the same directive: fetch the recipe for every Taskless task, even one already fetched earlier in the session. -Nothing here is **BREAKING**. The `init --json` envelope only gains fields; the human output only gains lines before an existing final line; the recipe body after the header block is unchanged. +Nothing here is **BREAKING**. `init` without a TTY already ran the batch install; with a TTY it ran the wizard, and that spelling now runs the batch install too, which is the one behaviour change a person can notice. The `init --json` envelope only gains fields; the human output only gains lines before an existing final line; recipe bodies are unchanged. ## Capabilities @@ -22,17 +23,17 @@ None. ### Modified Capabilities -- `cli-init`: a new requirement for the upgrade trailer and the `--json` fields it mirrors; the existing onboarding-trailer requirement is unchanged (the upgrade trailer prints before it). -- `cli-agent`: the recipe format's header becomes a two-line header block, the second line being the fetch-time directive. -- `cli-knowledge-prompts`: `header: false` suppresses the header block, not only its first line. +- `cli-init`: a new requirement for the upgrade trailer and the `--json` fields it mirrors; `init` becomes the batch path and the bare invocation the wizard; the canonical-rewrite requirement says an unchanged file is not reported. +- `cli-agent`: a new requirement for the directive the `agent` subcommand serves each recipe under. +- `cli-knowledge-prompts`: parity between the export and the command is stated modulo the `directive` option. - `skill-taskless`: the skill body includes a re-fetch directive. ## Impact -- `packages/cli/src/commands/init.ts`: trailer and envelope fields on the non-interactive path. -- `packages/cli/src/filesystem/migrate.ts`: the refusal message. -- `packages/cli/src/agent/*.md`: one header line per recipe; `init.md` rewritten and bumped to topic v2. -- `packages/cli/src/prompts/recipes.ts`: header stripping covers the block. +- `packages/cli/src/commands/init.ts` and `src/index.ts`: `init` always batch, the bare TTY invocation calls the wizard directly; trailer and envelope fields. +- `packages/cli/src/install/canonical.ts`: byte comparison before a canonical write. +- `packages/cli/src/agent/init.md`: rewritten and bumped to topic v2. +- `packages/cli/src/prompts/recipes.ts`: the `directive` option and the header block stripping. - `skills/taskless/SKILL.md`, `commands/tskl/tskl.md`: the directive. - Tests under `packages/cli/test/` for each of the above. - A `patch` changeset: the package is `0.y.z`, and nothing here is something a consumer must react to. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md index 60c3f177..919863c5 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md @@ -1,33 +1,26 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: Recipe files follow a consistent format +### Requirement: The agent subcommand serves each recipe under a fetch-time header -Every recipe file at `packages/cli/src/agent/.md` SHALL follow the canonical recipe template: a header block, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. +When `taskless agent ` serves a recipe, the rendered text SHALL carry a fetch-time directive as the second line of its header block, directly beneath the `# Topic:` line and before the blank line that closes the block. The directive SHALL state that the CLI resolved the text at the moment it was fetched, that the next Taskless task fetches it again with ` agent ` rather than reusing this copy, and that a session in which Taskless was installed or upgraded holds a stale skill until it is reloaded. It SHALL be identical for every topic except for the rendered invocation. -The header block SHALL be exactly two lines followed by one blank line. The first line SHALL be `# Topic: (CLI v%(CLI_VERSION)s / topic v)`. The second line SHALL be the fetch-time directive, identical across every recipe: it states that the text was resolved by the CLI at the moment it was fetched and that a later task, in the same session or another, fetches it again with `%(TASKLESS_CLI)s agent ` rather than reusing this copy. The directive is part of the header so that a cache-stable rendering can drop it together with the version line. +The directive is added by the renderer on the `agent` command's request, not written into the recipe files. A recipe file SHALL keep its single-line `# Topic:` header, and a prompt rendered through `@taskless/cli/prompts` SHALL NOT carry the directive: that export exists for a consumer embedding the text in its own prompt, where an instruction to re-run a CLI may be false. `PromptOptions.header: false` SHALL strip the whole header block, directive included, whenever one is present. -Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. +#### Scenario: A served recipe opens with the directive -#### Scenario: Recipe contains all template sections +- **WHEN** `taskless agent check` is run +- **THEN** line 1 of stdout SHALL be the `# Topic: check …` header +- **AND** line 2 SHALL be the fetch-time directive, naming the invocation and `agent ` +- **AND** line 3 SHALL be blank, followed by the recipe body unchanged -- **WHEN** any `.md` file is read -- **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer -- **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order +#### Scenario: The prompts export does not carry the directive -#### Scenario: Every recipe carries the fetch-time directive as its second line +- **WHEN** a consumer renders any topic through `@taskless/cli/prompts` with default options +- **THEN** the text SHALL begin with the `# Topic:` line followed directly by a blank line +- **AND** SHALL NOT contain the directive -- **WHEN** any `.md` file is read -- **THEN** its second line SHALL be the fetch-time directive, byte-identical to the directive in every other recipe -- **AND** the third line SHALL be blank +#### Scenario: Header suppression removes the directive with the version -#### Scenario: Recipe with --from input includes JSON schema placeholder - -- **WHEN** a topic recipe documents a CLI invocation that uses `--from ` -- **THEN** the recipe SHALL contain an `## Input schema` section with a code-fenced block containing the `%(INPUT_SCHEMA)s` placeholder -- **AND** the JSON Schema SHALL be derived at render time from the corresponding Zod schema in `packages/cli/src/schemas/` - -#### Scenario: Header version reflects build-time CLI version - -- **WHEN** the CLI bundle is built -- **THEN** the recipe header's `%(CLI_VERSION)s` placeholder SHALL be substituted at render time from `packages/cli/package.json` -- **AND** SHALL match the version reported by `taskless info` +- **WHEN** a recipe is rendered with the directive and `header: false` +- **THEN** the result SHALL contain neither the `# Topic:` line, the CLI version, nor the directive +- **AND** its body SHALL be byte-identical to the body of the default rendering diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md index 809f274c..fc57a131 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md @@ -1,15 +1,17 @@ ## ADDED Requirements -### Requirement: Non-interactive init reports what an upgrade changed and what follows +### Requirement: Init reports what an upgrade changed and what follows -`taskless init --no-interactive` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. +`taskless init` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. -On the human path, when the run changed anything (a migration ran, or any target had a skill or command written or removed), the CLI SHALL print an upgrade trailer AFTER the install summary and any reload notice and BEFORE the onboarding trailer, so the onboarding trailer stays the final line. The upgrade trailer SHALL: +On the human path, when the run changed anything (a migration ran, any target had a skill or command written or removed, or the recorded `install.cliVersion` moved), the CLI SHALL print an upgrade trailer directly AFTER the install summary and BEFORE any reload notice and the onboarding trailer. It prints first among the trailing notices because a reload, and anything the onboarding trailer proposes, come after the upgrade is understood. The upgrade trailer SHALL: - name each directory that now holds changed files (every target directory with a write or removal, and `.taskless/` when a migration ran or the recorded version moved), and state that those files belong in the next commit; - when the recorded `install.cliVersion` moved (a previous version was recorded and differs from the one this run recorded), state the transition and that `taskless update` reports what the upgrade means for existing rules; - be omitted entirely when nothing changed. A no-op re-install has nothing to commit and nothing to reconcile. +The onboarding trailer SHALL remain the final line of output. + Under `--json`, the envelope SHALL carry the same facts as fields rather than prose: - `cliVersion`: `{ previous: string | null, installed: string }`, where `previous` is the `install.cliVersion` read before this run and `null` when none was recorded; @@ -20,9 +22,10 @@ The `migrated` field is unchanged: present with the migration report when a migr #### Scenario: An upgrade prints the trailer with both parts -- **WHEN** `taskless init --no-interactive` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub +- **WHEN** `taskless init` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub - **THEN** stdout SHALL contain an upgrade trailer naming each directory that changed and saying those files belong in the next commit - **AND** the trailer SHALL name the previous and installed versions and point at `taskless update` +- **AND** the trailer SHALL appear before any reload notice - **AND** the onboarding trailer SHALL still be the final line #### Scenario: A change without a version move omits the update pointer @@ -33,30 +36,68 @@ The `migrated` field is unchanged: present with the migration report when a migr #### Scenario: A no-op re-install prints no upgrade trailer -- **WHEN** `taskless init --no-interactive` runs against a project that is already at the current scaffold version and whose every target reports up to date +- **WHEN** `taskless init` runs against a project that is already at the current scaffold version and whose every target reports up to date - **THEN** stdout SHALL NOT contain the upgrade trailer #### Scenario: The JSON envelope carries the version, targets, and changed flag -- **WHEN** `taskless init --no-interactive --json` runs +- **WHEN** `taskless init --json` runs - **THEN** the envelope SHALL contain `cliVersion.previous` (a string or `null`), `cliVersion.installed`, a `targets` array with one entry per install target, and a boolean `changed` - **AND** `changed` SHALL be `true` exactly when `migrated` is present, any target's written or removed list is non-empty, or `cliVersion.previous` is non-null and differs from `cliVersion.installed` -### Requirement: The migration refusal names the non-interactive path for a non-TTY caller +## MODIFIED Requirements + +### Requirement: Init subcommand installs skills into a repository -When a read-only command refuses a project whose scaffold is behind the CLI, the refusal SHALL tell the caller to run `init --no-interactive` when stdout is not a TTY, and `init` otherwise. An agent that follows the message verbatim SHALL never be sent to the wizard. +The CLI SHALL support a `taskless init` subcommand that installs the consolidated `taskless` skill into the current working directory's detected tool locations, and upgrades an existing install. `init` SHALL always run the batch install: every detected tool location (or `.agents/` fallback when none detected), without prompting and without an auth step, whether or not a TTY is attached. It is the path an agent, a script, and a CI job take, and the path a person takes to upgrade without answering prompts. The interactive wizard is reached by running the CLI with no subcommand in a TTY, never by `init`. -#### Scenario: A non-TTY caller is pointed at the non-interactive install +The `--no-interactive` flag is no longer defined. An invocation that still passes it SHALL behave exactly as `init` without it, so an existing script keeps working. -- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is not a TTY -- **THEN** the message SHALL contain `init --no-interactive` +There is exactly one mandatory skill in v0.7.0 (`taskless`) and zero optional skills. The wizard's optional-skill selection step SHALL be removed. -#### Scenario: A TTY caller is pointed at the wizard +The `--anonymous` flag is accepted on `init` as a no-op (init does not call the Taskless API directly). -- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is a TTY -- **THEN** the message SHALL contain `init` and SHALL NOT contain `--no-interactive` +#### Scenario: Running taskless init installs the consolidated skill -## MODIFIED Requirements +- **WHEN** a user runs `taskless init`, in a terminal or under a pipe +- **THEN** the CLI SHALL install the single `taskless` skill to every detected tool location without prompting for tools or auth +- **AND** SHALL NOT launch the wizard + +#### Scenario: A legacy --no-interactive flag is harmless + +- **WHEN** a script runs `taskless init --no-interactive` +- **THEN** the CLI SHALL behave exactly as for `taskless init` + +#### Scenario: Init removes obsolete v0.6 skill files + +- **WHEN** a user with v0.6 installed (10 per-task skills written) runs the v0.7.0 `taskless init` +- **THEN** the install plumbing SHALL read the previous install state from `.taskless/taskless.json` +- **AND** SHALL delete the 10 obsolete skill files and 6 obsolete command files +- **AND** SHALL write the new `taskless` skill and `tskl` command +- **AND** SHALL update `.taskless/taskless.json` install state to reflect the new layout + +#### Scenario: Init reports cleanup transparently + +- **WHEN** init removes obsolete files +- **THEN** the install summary output SHALL include "removed N obsolete skills" and "removed M obsolete commands" +- **AND** SHALL list the obsolete skill names so the user understands what changed + +### Requirement: Bare taskless invocation launches the init wizard + +The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND a TTY is attached. When stdout is NOT a TTY, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). + +#### Scenario: Bare taskless in a TTY launches the wizard + +- **WHEN** a user runs `taskless` with no subcommand and stdout is a TTY +- **THEN** the CLI SHALL launch the interactive wizard + +#### Scenario: Bare taskless without a TTY prints preamble + agent topic index + +- **WHEN** `taskless` is invoked with no subcommand and stdout is not a TTY +- **THEN** the CLI SHALL print a short preamble noting the non-interactive context (e.g. "For interactive install, run from a terminal. For agent recipes, run `taskless agent` (no args) for the topic index.") +- **AND** SHALL then print the agent topic index (same content as `taskless agent`) +- **AND** SHALL NOT launch the wizard +- **AND** SHALL NOT silently install ### Requirement: Update rewrites canonical content and preserves reference stubs diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md index d2bc4cc2..e9cd3754 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md @@ -1,20 +1,11 @@ ## MODIFIED Requirements -### Requirement: The version header is suppressible +### Requirement: The export and the agent command share one source and one renderer -Rendered prompts SHALL begin with a header block: a line naming the topic and the CLI version, then the fetch-time directive line, then one blank line. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing the block. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. Suppression SHALL remove the whole block, so a header-less rendering neither carries the version nor opens with a directive that only makes sense beside it. +The prompt export SHALL be sourced from the same embedded `agent/*.md` content that `commands/agent.ts` serves, and SHALL render it through the same render path, with no duplicated embedding and no duplicated interpolation logic. Both surfaces SHALL return identical text for the same topic and equivalent options. The one option the `agent` command sets that the export does not default to is `directive`: the command serves a fetch, so it asks for the fetch-time directive, and the export leaves it off. A consumer that passes `directive: true` SHALL receive exactly what the command prints. -#### Scenario: Header suppressed for a cache-stable system prompt +#### Scenario: Parity between import and agent command -- **WHEN** a consumer calls a prompt with `header: false` -- **THEN** the returned text omits the `# Topic: …` line and the directive line and contains no CLI version string, while the body is otherwise identical to the default rendering - -#### Scenario: Header present by default - -- **WHEN** a prompt is called with no options, or the `agent` command renders a topic -- **THEN** the header block is present, exactly as it renders today - -#### Scenario: Build defines are inlined into the prompts entry - -- **WHEN** a rendered prompt is inspected from the built `dist/prompts.js` -- **THEN** it contains no un-inlined build-define identifier (e.g. a literal `__VERSION__`) +- **WHEN** the `agent` command renders topic `T` and a consumer calls `getPrompt("T", { directive: true })` +- **THEN** the two texts are identical, including under a non-prod build target where the CLI invocation is rewritten +- **AND** `getPrompt("T")` with default options is the same text without the directive line diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md index 83153038..769f8d55 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md @@ -4,29 +4,31 @@ their tests. It touches no open branch and each part is small enough that splitting would separate a change from the test that pins it. -## 1. Non-interactive init reports the upgrade +## 1. `init` is the batch path; the bare invocation is the wizard -- [x] 1.1 Add an upgrade-trailer renderer beside `getOnboardTrailer`, taking the changed directories, whether a migration ran, and the previous/installed versions. Return `undefined` when nothing changed, so the no-op case is decided in one place -- [x] 1.2 Reuse the reload banner's version-moved test for the `update` pointer, rather than writing a second comparison next to it -- [x] 1.3 In `runNonInteractive`, collect per-target results into a `targets` list (`dir`, `mode`, four name lists) and derive `changed` from it plus `migrated`. Return `previousCliVersion` and `cliVersion` alongside so the caller does not re-read state -- [x] 1.4 Print the trailer after the reload notice and before the onboarding trailer on the human path; add `cliVersion`, `targets`, and `changed` to the `--json` envelope, keeping `migrated` presence-gated as it is -- [x] 1.5 Tests: upgrade with version move (both parts, onboarding trailer still last), change without version move (no `update` pointer), no-op re-install (no trailer), and the envelope fields with `changed` agreeing with `migrated` and the lists. Update any test that pins the envelope to an exact object +- [x] 1.1 Remove the `--no-interactive` flag and the TTY/CI detection from `initCommand`; `run` goes straight to `runNonInteractive` +- [x] 1.2 In `index.ts`, call `runWizard` directly for a bare invocation in a TTY instead of delegating to `initCommand`; name `init` (no flag) in the non-TTY preamble +- [x] 1.3 Tests: `init` under a pipe installs with no "detected non-interactive" notice; a legacy `--no-interactive` is a no-op. Drop the flag from every test argv -## 2. The migration refusal names the right command +## 2. Init reports the upgrade -- [x] 2.1 In `requireCurrentSchema`'s refusal, append `--no-interactive` when `process.stdout.isTTY` is not `true` -- [x] 2.2 Test both wordings through the built CLI; the existing `no-implicit-migration` suite is the place, since it already runs `check` against a behind-the-CLI fixture with a piped stdout +- [x] 2.1 Add an upgrade-trailer renderer in `install/upgrade-trailer.ts`, taking the changed directories, whether a migration ran, and the previous/installed versions. Return `undefined` when nothing changed, so the no-op case is decided in one place +- [x] 2.2 Reuse the reload banner's version-moved test for the `update` pointer and for counting a version move as a change +- [x] 2.3 In `runNonInteractive`, collect per-target results into a `targets` list (`dir`, `mode`, four name lists) and derive `changed` from it plus `migrated` plus the version move. Return `previousCliVersion` and `cliVersion` alongside +- [x] 2.4 Print the trailer directly after the summary, before the reload notice and the onboarding trailer; add `cliVersion`, `targets`, and `changed` to the `--json` envelope, keeping `migrated` presence-gated +- [x] 2.5 Make `writeCanonicalSkill`/`writeCanonicalCommand` compare bytes and return `{ path, changed }`, so an identical canonical file is neither rewritten nor reported. Replace the `apply-install-plan` test that pinned the unconditional rewrite +- [x] 2.6 Tests: upgrade with version move (both parts, trailer before the reload banner, onboarding trailer still last), change without version move (no `update` pointer), no-op re-install (no trailer), and the envelope fields with `changed` agreeing with `migrated`, the lists, and the version -## 3. The init recipe addresses the agent +## 3. The `agent init` recipe addresses the agent -- [x] 3.1 Rewrite `init.md` (topic v2): the non-interactive invocation as the primary step, the `--json` envelope, and the three follow-ups (commit the named directories, run `update` after a version move, treat a session that predates the install as holding stale skills). Keep the wizard description for a human reader under its own heading +- [x] 3.1 Rewrite `init.md` (topic v2): `init` as the primary step, the `--json` envelope, and what follows (tell the user which paths need committing, run `update` after a version move, treat a session that predates the install as holding stale skills). Keep the wizard description for a human reader under its own heading - [x] 3.2 Keep `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also` in order so the format test still passes -## 4. Recipes carry the fetch-time directive +## 4. The `agent` subcommand serves recipes under the directive -- [x] 4.1 Add the directive as line 2 of every `packages/cli/src/agent/*.md`, byte-identical, using `%(TASKLESS_CLI)s agent ` for the re-fetch command -- [x] 4.2 Extend `stripHeader` to drop through the first blank line, keeping the first-line `# Topic:` anchor. Verify `header: false` output contains neither the version nor the directive and that the body is byte-identical to the default rendering's body -- [x] 4.3 Add a test that every recipe's second line is the directive and third line is blank, so a new recipe cannot omit it +- [x] 4.1 Add `RecipeOptions.directive` (default `false`) and `fetchTimeDirective(invocation)`; insert it as line 2 of the header block when set. `commands/agent.ts` and the `update` command pass `true` +- [x] 4.2 Extend `stripHeader` to drop through the first blank line, keeping the first-line `# Topic:` anchor, so `header: false` removes the directive with the version +- [x] 4.3 Tests: every recipe file keeps a one-line header; the prompts export carries no directive; the served text has it as line 2 with the invocation and the stale-skill note; `header: false` strips it; parity with the export holds under `directive: true` ## 5. The skill and command say a recipe is per-task @@ -36,6 +38,6 @@ splitting would separate a change from the test that pins it. ## 6. Ship -- [x] 6.1 `patch` changeset for `@taskless/cli`, saying what an agent now sees after `init` and that recipes carry the directive +- [x] 6.1 `patch` changeset for `@taskless/cli` - [x] 6.2 `pnpm build && pnpm typecheck && pnpm lint && pnpm test` -- [x] 6.3 Archive the change on this PR, then run the pre-archive scenario check from CLAUDE.md against each of the three MODIFIED requirements +- [x] 6.3 Archive the change on this PR, then run the pre-archive scenario check from CLAUDE.md against every MODIFIED requirement diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 4c0a68df..c316c896 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -55,24 +55,14 @@ Recipe files SHALL be located at `packages/cli/src/agent/` as plain `.txt` files ### Requirement: Recipe files follow a consistent format -Every recipe file at `packages/cli/src/agent/.md` SHALL follow the canonical recipe template: a header block, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. - -The header block SHALL be exactly two lines followed by one blank line. The first line SHALL be `# Topic: (CLI v%(CLI_VERSION)s / topic v)`. The second line SHALL be the fetch-time directive, identical across every recipe: it states that the text was resolved by the CLI at the moment it was fetched and that a later task, in the same session or another, fetches it again with `%(TASKLESS_CLI)s agent ` rather than reusing this copy. The directive is part of the header so that a cache-stable rendering can drop it together with the version line. - -Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. +Every recipe file at `packages/cli/src/agent/.txt` SHALL follow the canonical recipe template: a single-line header `# Topic: (CLI v%(CLI_VERSION)s / topic v)`, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. #### Scenario: Recipe contains all template sections -- **WHEN** any `.md` file is read +- **WHEN** any `.txt` file is read - **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer - **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order -#### Scenario: Every recipe carries the fetch-time directive as its second line - -- **WHEN** any `.md` file is read -- **THEN** its second line SHALL be the fetch-time directive, byte-identical to the directive in every other recipe -- **AND** the third line SHALL be blank - #### Scenario: Recipe with --from input includes JSON schema placeholder - **WHEN** a topic recipe documents a CLI invocation that uses `--from ` @@ -309,3 +299,28 @@ An automated check SHALL fail when a bare `` `taskless ` `` invocati - **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 - **AND** it SHALL anchor on the invocation the running build actually renders, so the check does not pass vacuously under a `nightly`, `dev`, or `self` build whose rendered invocation names neither `taskless` nor `@taskless/cli` - **AND** it SHALL fail when it finds no cross-reference at all, since an empty result is otherwise indistinguishable from every reference resolving + +### Requirement: The agent subcommand serves each recipe under a fetch-time header + +When `taskless agent ` serves a recipe, the rendered text SHALL carry a fetch-time directive as the second line of its header block, directly beneath the `# Topic:` line and before the blank line that closes the block. The directive SHALL state that the CLI resolved the text at the moment it was fetched, that the next Taskless task fetches it again with ` agent ` rather than reusing this copy, and that a session in which Taskless was installed or upgraded holds a stale skill until it is reloaded. It SHALL be identical for every topic except for the rendered invocation. + +The directive is added by the renderer on the `agent` command's request, not written into the recipe files. A recipe file SHALL keep its single-line `# Topic:` header, and a prompt rendered through `@taskless/cli/prompts` SHALL NOT carry the directive: that export exists for a consumer embedding the text in its own prompt, where an instruction to re-run a CLI may be false. `PromptOptions.header: false` SHALL strip the whole header block, directive included, whenever one is present. + +#### Scenario: A served recipe opens with the directive + +- **WHEN** `taskless agent check` is run +- **THEN** line 1 of stdout SHALL be the `# Topic: check …` header +- **AND** line 2 SHALL be the fetch-time directive, naming the invocation and `agent ` +- **AND** line 3 SHALL be blank, followed by the recipe body unchanged + +#### Scenario: The prompts export does not carry the directive + +- **WHEN** a consumer renders any topic through `@taskless/cli/prompts` with default options +- **THEN** the text SHALL begin with the `# Topic:` line followed directly by a blank line +- **AND** SHALL NOT contain the directive + +#### Scenario: Header suppression removes the directive with the version + +- **WHEN** a recipe is rendered with the directive and `header: false` +- **THEN** the result SHALL contain neither the `# Topic:` line, the CLI version, nor the directive +- **AND** its body SHALL be byte-identical to the body of the default rendering diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index a7800573..ebbfb420 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -8,7 +8,9 @@ The `taskless init` subcommand: how it detects the AI tools present in a reposit ### Requirement: Init subcommand installs skills into a repository -The CLI SHALL support a `taskless init` subcommand that installs the consolidated `taskless` skill into the current working directory's detected tool locations. The subcommand SHALL also be available as `taskless update` (alias). By default, `init` SHALL launch the interactive wizard. When invoked with `--no-interactive`, `init` SHALL preserve the prior batch-install behavior: install the consolidated skill to every detected tool location (or `.agents/` fallback when none detected) without prompting and without an auth step. +The CLI SHALL support a `taskless init` subcommand that installs the consolidated `taskless` skill into the current working directory's detected tool locations, and upgrades an existing install. `init` SHALL always run the batch install: every detected tool location (or `.agents/` fallback when none detected), without prompting and without an auth step, whether or not a TTY is attached. It is the path an agent, a script, and a CI job take, and the path a person takes to upgrade without answering prompts. The interactive wizard is reached by running the CLI with no subcommand in a TTY, never by `init`. + +The `--no-interactive` flag is no longer defined. An invocation that still passes it SHALL behave exactly as `init` without it, so an existing script keeps working. There is exactly one mandatory skill in v0.7.0 (`taskless`) and zero optional skills. The wizard's optional-skill selection step SHALL be removed. @@ -16,9 +18,14 @@ The `--anonymous` flag is accepted on `init` as a no-op (init does not call the #### Scenario: Running taskless init installs the consolidated skill -- **WHEN** a user runs `taskless init` in an interactive terminal -- **THEN** the wizard SHALL prompt for tool locations and auth, then install the single `taskless` skill -- **AND** SHALL NOT prompt for optional skills (none exist) +- **WHEN** a user runs `taskless init`, in a terminal or under a pipe +- **THEN** the CLI SHALL install the single `taskless` skill to every detected tool location without prompting for tools or auth +- **AND** SHALL NOT launch the wizard + +#### Scenario: A legacy --no-interactive flag is harmless + +- **WHEN** a script runs `taskless init --no-interactive` +- **THEN** the CLI SHALL behave exactly as for `taskless init` #### Scenario: Init removes obsolete v0.6 skill files @@ -349,12 +356,12 @@ The `init` subcommand SHALL use the resolved working directory from the global ` ### Requirement: Bare taskless invocation launches the init wizard -The CLI entry point SHALL delegate to `init` when invoked with no positional subcommand AND a TTY is attached. When stdout is NOT a TTY, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). +The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND a TTY is attached. When stdout is NOT a TTY, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). #### Scenario: Bare taskless in a TTY launches the wizard - **WHEN** a user runs `taskless` with no subcommand and stdout is a TTY -- **THEN** the CLI SHALL behave as if `taskless init` were invoked +- **THEN** the CLI SHALL launch the interactive wizard #### Scenario: Bare taskless without a TTY prints preamble + agent topic index @@ -759,16 +766,18 @@ An install SHALL also reclaim a stub whose recovery command names a build other - **THEN** the install SHALL rewrite that stub - **AND** a subsequent install SHALL leave the rewritten stub untouched -### Requirement: Non-interactive init reports what an upgrade changed and what follows +### Requirement: Init reports what an upgrade changed and what follows -`taskless init --no-interactive` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. +`taskless init` SHALL tell its caller what an install changed beyond the files it wrote, because its caller is usually an agent that `check` sent there and that must decide what to do next. -On the human path, when the run changed anything (a migration ran, or any target had a skill or command written or removed), the CLI SHALL print an upgrade trailer AFTER the install summary and any reload notice and BEFORE the onboarding trailer, so the onboarding trailer stays the final line. The upgrade trailer SHALL: +On the human path, when the run changed anything (a migration ran, any target had a skill or command written or removed, or the recorded `install.cliVersion` moved), the CLI SHALL print an upgrade trailer directly AFTER the install summary and BEFORE any reload notice and the onboarding trailer. It prints first among the trailing notices because a reload, and anything the onboarding trailer proposes, come after the upgrade is understood. The upgrade trailer SHALL: - name each directory that now holds changed files (every target directory with a write or removal, and `.taskless/` when a migration ran or the recorded version moved), and state that those files belong in the next commit; - when the recorded `install.cliVersion` moved (a previous version was recorded and differs from the one this run recorded), state the transition and that `taskless update` reports what the upgrade means for existing rules; - be omitted entirely when nothing changed. A no-op re-install has nothing to commit and nothing to reconcile. +The onboarding trailer SHALL remain the final line of output. + Under `--json`, the envelope SHALL carry the same facts as fields rather than prose: - `cliVersion`: `{ previous: string | null, installed: string }`, where `previous` is the `install.cliVersion` read before this run and `null` when none was recorded; @@ -779,9 +788,10 @@ The `migrated` field is unchanged: present with the migration report when a migr #### Scenario: An upgrade prints the trailer with both parts -- **WHEN** `taskless init --no-interactive` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub +- **WHEN** `taskless init` runs against a project whose recorded `install.cliVersion` differs from the running CLI, and the run writes at least one stub - **THEN** stdout SHALL contain an upgrade trailer naming each directory that changed and saying those files belong in the next commit - **AND** the trailer SHALL name the previous and installed versions and point at `taskless update` +- **AND** the trailer SHALL appear before any reload notice - **AND** the onboarding trailer SHALL still be the final line #### Scenario: A change without a version move omits the update pointer @@ -792,25 +802,11 @@ The `migrated` field is unchanged: present with the migration report when a migr #### Scenario: A no-op re-install prints no upgrade trailer -- **WHEN** `taskless init --no-interactive` runs against a project that is already at the current scaffold version and whose every target reports up to date +- **WHEN** `taskless init` runs against a project that is already at the current scaffold version and whose every target reports up to date - **THEN** stdout SHALL NOT contain the upgrade trailer #### Scenario: The JSON envelope carries the version, targets, and changed flag -- **WHEN** `taskless init --no-interactive --json` runs +- **WHEN** `taskless init --json` runs - **THEN** the envelope SHALL contain `cliVersion.previous` (a string or `null`), `cliVersion.installed`, a `targets` array with one entry per install target, and a boolean `changed` - **AND** `changed` SHALL be `true` exactly when `migrated` is present, any target's written or removed list is non-empty, or `cliVersion.previous` is non-null and differs from `cliVersion.installed` - -### Requirement: The migration refusal names the non-interactive path for a non-TTY caller - -When a read-only command refuses a project whose scaffold is behind the CLI, the refusal SHALL tell the caller to run `init --no-interactive` when stdout is not a TTY, and `init` otherwise. An agent that follows the message verbatim SHALL never be sent to the wizard. - -#### Scenario: A non-TTY caller is pointed at the non-interactive install - -- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is not a TTY -- **THEN** the message SHALL contain `init --no-interactive` - -#### Scenario: A TTY caller is pointed at the wizard - -- **WHEN** `check` refuses a behind-the-CLI scaffold and stdout is a TTY -- **THEN** the message SHALL contain `init` and SHALL NOT contain `--no-interactive` diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index 25dd60b2..49a7cf40 100644 --- a/openspec/specs/cli-knowledge-prompts/spec.md +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -43,12 +43,13 @@ The export SHALL provide a `PromptTopic` union of the available canonical topics ### Requirement: The export and the agent command share one source and one renderer -The prompt export SHALL be sourced from the same embedded `agent/*.txt` content that `commands/agent.ts` serves, and SHALL render it through the same render path, with no duplicated embedding and no duplicated interpolation logic. Both surfaces SHALL return identical text for the same topic and equivalent options. +The prompt export SHALL be sourced from the same embedded `agent/*.md` content that `commands/agent.ts` serves, and SHALL render it through the same render path, with no duplicated embedding and no duplicated interpolation logic. Both surfaces SHALL return identical text for the same topic and equivalent options. The one option the `agent` command sets that the export does not default to is `directive`: the command serves a fetch, so it asks for the fetch-time directive, and the export leaves it off. A consumer that passes `directive: true` SHALL receive exactly what the command prints. #### Scenario: Parity between import and agent command -- **WHEN** the `agent` command renders topic `T` and a consumer calls `getPrompt("T")` +- **WHEN** the `agent` command renders topic `T` and a consumer calls `getPrompt("T", { directive: true })` - **THEN** the two texts are identical, including under a non-prod build target where the CLI invocation is rewritten +- **AND** `getPrompt("T")` with default options is the same text without the directive line ### Requirement: The export returns fully-rendered prompt text @@ -83,17 +84,17 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub ### Requirement: The version header is suppressible -Rendered prompts SHALL begin with a header block: a line naming the topic and the CLI version, then the fetch-time directive line, then one blank line. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing the block. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. Suppression SHALL remove the whole block, so a header-less rendering neither carries the version nor opens with a directive that only makes sense beside it. +Rendered prompts SHALL begin with a header line naming the topic and the CLI version. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing it. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged. #### Scenario: Header suppressed for a cache-stable system prompt - **WHEN** a consumer calls a prompt with `header: false` -- **THEN** the returned text omits the `# Topic: …` line and the directive line and contains no CLI version string, while the body is otherwise identical to the default rendering +- **THEN** the returned text omits the `# Topic: …` line and contains no CLI version string, while the body is otherwise identical to the default rendering #### Scenario: Header present by default - **WHEN** a prompt is called with no options, or the `agent` command renders a topic -- **THEN** the header block is present, exactly as it renders today +- **THEN** the header line is present, exactly as it renders today #### Scenario: Build defines are inlined into the prompts entry diff --git a/packages/cli/README.md b/packages/cli/README.md index 1ed8935b..12d8d5ea 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -16,10 +16,11 @@ npx @taskless/cli Run with no arguments in a terminal to launch the installer, which detects the agent tools in your project (Claude Code, Cursor, OpenCode) and installs into -each of them. For scripted installs, skip the prompts: +each of them. For scripted installs and upgrades, `init` runs the same install +without prompts, in a terminal or not: ```bash -npx @taskless/cli init --no-interactive +npx @taskless/cli init ``` New to Taskless? Run `npx @taskless/cli onboard` after installing. It walks your diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index df4681a1..001aacf2 100644 --- a/packages/cli/src/agent/auth.md +++ b/packages/cli/src/agent/auth.md @@ -1,5 +1,4 @@ # Topic: auth (CLI v%(CLI_VERSION)s / topic v1) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Manage Taskless authentication. Three branches: diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index d8ba970c..5f0129d4 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -1,5 +1,4 @@ # Topic: check (CLI v%(CLI_VERSION)s / topic v2) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Run the applicable rules against the codebase and report matches. Two diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index ac496e61..61ca69e2 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,5 +1,4 @@ # Topic: ci (CLI v%(CLI_VERSION)s / topic v1) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Wire `%(TASKLESS_CLI)s check` into the user's existing CI so rules run diff --git a/packages/cli/src/agent/create-legacy-rule.md b/packages/cli/src/agent/create-legacy-rule.md index 718d2184..d12a7b0e 100644 --- a/packages/cli/src/agent/create-legacy-rule.md +++ b/packages/cli/src/agent/create-legacy-rule.md @@ -1,5 +1,4 @@ # Topic: create-legacy-rule (CLI v%(CLI_VERSION)s / topic v1) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-legacy-rule`. It helps you write a rule for a linter the diff --git a/packages/cli/src/agent/create-remote-rule.md b/packages/cli/src/agent/create-remote-rule.md index 51e63973..82720e5c 100644 --- a/packages/cli/src/agent/create-remote-rule.md +++ b/packages/cli/src/agent/create-remote-rule.md @@ -1,5 +1,4 @@ # Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v3) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-remote-rule`. It helps you have the Taskless service diff --git a/packages/cli/src/agent/create-runtime-rule.md b/packages/cli/src/agent/create-runtime-rule.md index 046f7d58..3a485a82 100644 --- a/packages/cli/src/agent/create-runtime-rule.md +++ b/packages/cli/src/agent/create-runtime-rule.md @@ -1,5 +1,4 @@ # Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v2) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-runtime-rule`. It helps you write a runtime rule: a diff --git a/packages/cli/src/agent/create-sg-rule.md b/packages/cli/src/agent/create-sg-rule.md index df8b2e51..40e41359 100644 --- a/packages/cli/src/agent/create-sg-rule.md +++ b/packages/cli/src/agent/create-sg-rule.md @@ -1,5 +1,4 @@ # Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v5) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-sg-rule`. It helps you write an ast-grep rule: a check diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index c1392dec..b937dfa8 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -1,5 +1,4 @@ # Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v7) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `create-vale-rule`. It helps you write a Vale rule: a check over diff --git a/packages/cli/src/agent/delete-rule.md b/packages/cli/src/agent/delete-rule.md index 18d4c828..5cda434e 100644 --- a/packages/cli/src/agent/delete-rule.md +++ b/packages/cli/src/agent/delete-rule.md @@ -1,5 +1,4 @@ # Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v4) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Remove a rule and its associated test files from `.taskless/`. Does not diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index 1e3ba567..faf3e7f8 100644 --- a/packages/cli/src/agent/detect.md +++ b/packages/cli/src/agent/detect.md @@ -1,5 +1,4 @@ # Topic: detect (CLI v%(CLI_VERSION)s / topic v1) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Scan the working directory for the linters it configures, the diff --git a/packages/cli/src/agent/improve-rule.anonymous.md b/packages/cli/src/agent/improve-rule.anonymous.md index ef1e99cc..8088b7a9 100644 --- a/packages/cli/src/agent/improve-rule.anonymous.md +++ b/packages/cli/src/agent/improve-rule.anonymous.md @@ -1,5 +1,4 @@ # Topic: improve-rule (anonymous) (CLI v%(CLI_VERSION)s / topic v2) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Iterate on an existing ast-grep rule **locally** without contacting diff --git a/packages/cli/src/agent/improve-rule.md b/packages/cli/src/agent/improve-rule.md index fa45a247..449de825 100644 --- a/packages/cli/src/agent/improve-rule.md +++ b/packages/cli/src/agent/improve-rule.md @@ -1,5 +1,4 @@ # Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v5) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Iterate on an existing Taskless rule. The CLI submits the user's diff --git a/packages/cli/src/agent/info.md b/packages/cli/src/agent/info.md index 528e8e4b..127b223a 100644 --- a/packages/cli/src/agent/info.md +++ b/packages/cli/src/agent/info.md @@ -1,5 +1,4 @@ # Topic: info (CLI v%(CLI_VERSION)s / topic v1) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Report local Taskless state: CLI version, installed skill versions diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 3d18c137..6f26e356 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,5 +1,4 @@ # Topic: init (CLI v%(CLI_VERSION)s / topic v2) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Install or update the Taskless skill in this project, and migrate the @@ -16,13 +15,14 @@ is the first of three steps, not the whole job. - None at the project level. The command works in any directory and bootstraps `.taskless/` on first run. - No auth. `init` never calls the Taskless API. -- The wizard needs a TTY. You do not have one, so use the flag below. +- No TTY needed. `init` is the batch path in every context; the wizard + is only reached by running the CLI with no subcommand in a terminal. ## Steps -1. **Run the non-interactive install.** +1. **Run the install.** ``` - %(TASKLESS_CLI)s init --no-interactive --json + %(TASKLESS_CLI)s init --json ``` The envelope: ```json @@ -48,9 +48,10 @@ is the first of three steps, not the whole job. - `targets` lists every install location and what this run wrote or removed there, by name. `mode: "canonical"` is the `.taskless/` store; `reference` is a tool directory holding stubs. - - `changed` is `true` when a migration ran or any target list is - non-empty. When it is `false`, stop here: nothing to commit, - nothing to reconcile. + - `changed` is `true` when a migration ran, any target list is + non-empty, or `cliVersion` moved (that rewrites + `.taskless/taskless.json`). When it is `false`, stop here: nothing + to commit, nothing to reconcile. - `migrated` is present only when a migration ran, with the paths it added, rewrote, or deleted. @@ -58,12 +59,12 @@ is the first of three steps, not the whole job. then a trailer naming the directories that changed and, after a version move, pointing at `update`. -2. **Commit what changed.** Every `targets[].dir` with a non-empty list, - plus `.taskless/` and any `migrated.files` entries, now holds changes - that belong in the working tree's next commit. Stage them with the - work you were doing, or as their own commit if the user prefers, and - say what Taskless rewrote and why (a CLI upgrade, a layout migration). - Do not leave them for whoever commits next. +2. **Tell the user what needs committing.** Every `targets[].dir` with + a non-empty list, plus `.taskless/` and any `migrated.files` entries, + now holds changes that belong in version control. Name those paths and + say what Taskless rewrote and why (a CLI upgrade, a layout migration), + so the user can include them in the commit they choose. Do not stage + or commit on your own; the git operations are theirs. 3. **After a version move, reconcile the rules.** When `cliVersion.previous` is non-null and differs from `installed`, run diff --git a/packages/cli/src/agent/onboard.md b/packages/cli/src/agent/onboard.md index 5e25a668..eff1932f 100644 --- a/packages/cli/src/agent/onboard.md +++ b/packages/cli/src/agent/onboard.md @@ -1,5 +1,4 @@ # Topic: onboard (CLI v%(CLI_VERSION)s / topic v3) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Help a user who has just installed Taskless go from zero rules to a diff --git a/packages/cli/src/agent/route.md b/packages/cli/src/agent/route.md index 12ae1e64..cf9ec77d 100644 --- a/packages/cli/src/agent/route.md +++ b/packages/cli/src/agent/route.md @@ -1,5 +1,4 @@ # Topic: route (CLI v%(CLI_VERSION)s / topic v5) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Turn "write me a rule that…" into one command to run. This is the front diff --git a/packages/cli/src/agent/rule-meta.md b/packages/cli/src/agent/rule-meta.md index 8b90b8b6..d0441bf8 100644 --- a/packages/cli/src/agent/rule-meta.md +++ b/packages/cli/src/agent/rule-meta.md @@ -1,5 +1,4 @@ # Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v3) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Report what `%(TASKLESS_CLI)s rule meta` does today, so no recipe and no diff --git a/packages/cli/src/agent/rule.md b/packages/cli/src/agent/rule.md index fd282385..f42c5f3e 100644 --- a/packages/cli/src/agent/rule.md +++ b/packages/cli/src/agent/rule.md @@ -1,5 +1,4 @@ # Topic: rule (CLI v%(CLI_VERSION)s / topic v2) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Umbrella for rule operations. Fetch the topic for the action you want. diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index cd9d580a..f9f8ef26 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,5 +1,4 @@ # Topic: update (CLI v%(CLI_VERSION)s / topic v5) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## You are here This is `update`. It tells you what an upgrade changed for the rules diff --git a/packages/cli/src/agent/verify-rule.md b/packages/cli/src/agent/verify-rule.md index 5127b913..659f5378 100644 --- a/packages/cli/src/agent/verify-rule.md +++ b/packages/cli/src/agent/verify-rule.md @@ -1,5 +1,4 @@ # Topic: verify-rule (CLI v%(CLI_VERSION)s / topic v3) -Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, fetches it again with `%(TASKLESS_CLI)s agent `; do not reuse this copy. ## Goal Check that a rule is well-formed (`verify`), then run its tests diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 11b3fd7e..585a7d94 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -185,6 +185,11 @@ export function createAgentCommand(subCommands: SubCommandsDef) { const recipe = getRecipe(key, { anonymous: args.anonymous, invocation: detectCliInvocation(processLauncherContext()), + // What this command serves IS a fetch, so the served text says so: + // resolved now, fetch again next task, and a session that installed + // or upgraded mid-way holds a stale skill. The prompts export leaves + // it off; see `RecipeOptions.directive`. + directive: true, }); if (recipe) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index af9f688a..80010c1f 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -17,7 +17,6 @@ import { getReloadNotice, versionMoved } from "../install/reload-notice"; import { getUpgradeTrailer } from "../install/upgrade-trailer"; import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; -import { runWizard } from "../wizard"; import { getCliVersion } from "../wizard/intro"; import { getOnboardTrailer } from "./onboard"; @@ -39,15 +38,6 @@ import { CLIError } from "../util/cli-error"; import { buildInvocation } from "../util/invocation"; import { makeErrorEnvelope } from "../types/errors"; -function shouldRunInteractively(noInteractiveFlag: boolean): boolean { - if (noInteractiveFlag) return false; - if (process.env.CI === "true" || process.env.CI === "1") return false; - // Require both stdin and stdout to be TTYs — clack reads from stdin, so a - // piped stdin (common in scripts) would hang the wizard even when stdout - // is a TTY. - return process.stdout.isTTY === true && process.stdin.isTTY === true; -} - export const initCommand = defineCommand({ meta: { name: "init", @@ -65,38 +55,23 @@ export const initCommand = defineCommand({ "Emit the install result as JSON, including what a migration moved", default: false, }, - "no-interactive": { - type: "boolean", - description: - "Install every mandatory skill to every detected tool without prompting", - default: false, - }, anonymous: { type: "boolean", description: "Accepted for compatibility; init has no auth dependency", default: false, }, }, + // `init` is the batch install in every context: an agent, a script, a CI + // job, and a person upgrading without prompts all run the same thing. The + // wizard is reached only by a bare invocation in a TTY (see `index.ts`). + // + // `--no-interactive` used to select this path and is no longer defined. + // citty passes an undefined flag through without complaint, so a script + // that still spells it out gets exactly this behaviour. async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - const interactive = shouldRunInteractively(args["no-interactive"]); - - if (interactive) { - const result = await runWizard({ cwd }); - if (result.status === "cancelled") { - process.exitCode = 1; - } - return; - } - - if (!args["no-interactive"] && process.stdout.isTTY !== true) { - console.error( - "Detected non-interactive context (no TTY); running non-interactive install." - ); - } - const result = await runNonInteractive(cwd, { json: args.json }); if (args.json) { console.log( @@ -125,11 +100,9 @@ export const initCommand = defineCommand({ }) ); } else { - if (result.reloadNotice !== undefined) { - console.log(result.reloadNotice); - } - // Before the onboarding trailer, which stays the final line: several - // scenarios pin it there, and an agent reads all of stdout anyway. + // First among the trailing notices, directly after the summary: a + // reload, and anything the onboarding line proposes, come after the + // upgrade is understood. The onboarding trailer stays the final line. const upgradeTrailer = getUpgradeTrailer({ changedDirectories: result.targets .filter((target) => targetChanged(target)) @@ -142,6 +115,9 @@ export const initCommand = defineCommand({ if (upgradeTrailer !== undefined) { console.log(upgradeTrailer); } + if (result.reloadNotice !== undefined) { + console.log(result.reloadNotice); + } console.log( getOnboardTrailer({ commandsInstalled: result.commandsInstalled }) ); @@ -155,7 +131,7 @@ export const initCommand = defineCommand({ * `update` is about the RULES, not about the installation. * * It used to mean "reinstall the skills non-interactively", which is what - * `init --no-interactive` already does through the very same + * `init` already does through the very same * `runNonInteractive`, and what the wizard does on any ordinary run. A second * name for that bought nothing, and it held a word that describes the job an * agent actually needs: deciding whether the rules in front of it need @@ -206,6 +182,7 @@ export const updateCommand = defineCommand({ const recipe = getRecipe("update", { anonymous: args.anonymous, invocation: detectCliInvocation(processLauncherContext()), + directive: true, }); if (recipe === undefined) { console.error("No `update` recipe is bundled with this CLI."); @@ -317,7 +294,7 @@ async function runNonInteractive( // point. `ensureTasklessDirectory` mkdir -p's, so afterwards a pre-existing // project is indistinguishable from a fresh one. // - // This path is also `init --no-interactive`, whose documented job is + // This path is `init` itself, whose documented job is // refreshing an EXISTING project. Stamping there would mark a project that // never walked the ledger as fully reconciled and skip every entry, which is // the silent skip this feature exists to prevent. diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index da2960b9..6adf9910 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -106,6 +106,9 @@ export const onboardCommand = defineCommand({ // for anyone running a published build. const recipe = getRecipe("onboard", { invocation: detectCliInvocation(processLauncherContext()), + // Served text is a fetch, the same as `agent onboard`, which this must + // match byte for byte. + directive: true, }); if (recipe === undefined) { // Should not happen — onboard.md is embedded at build time. diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index bc74a5f5..eb0cd663 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -426,16 +426,11 @@ export async function requireCurrentSchema(cwd: string): Promise { const pending = await pendingMigration(cwd); if (pending === undefined) return; - // The same test `init` itself applies before choosing wizard or batch. An - // agent follows this message verbatim, and a bare `init` under a pipe would - // print the topic index and change nothing. - const initCommand = - process.stdout.isTTY === true ? "init" : "init --no-interactive"; throw new CLIError( `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()} ${initCommand}\` to migrate, then run this again.`, + `Run \`${buildInvocation()} init\` to migrate, then run this again.`, "SCAFFOLD_MIGRATION_REQUIRED" ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7140889c..ff541be8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,7 @@ import { shutdownTelemetry, } from "./telemetry"; import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; +import { runWizard } from "./wizard"; import { DIR_FLAGS, hasHelpFlag, splitRawArguments } from "./util/argv"; import { showResolvedUsage } from "./util/help"; import { CLIError } from "./util/cli-error"; @@ -94,15 +95,22 @@ const main = defineCommand({ // TTY → run the interactive wizard. Non-TTY → print a short preamble // explaining the context and then delegate to `agent` so agents and // pipes see the topic index. + // + // The wizard is reached from HERE and nowhere else. `init` is the batch + // path in every context, so a bare invocation is the only spelling that + // prompts, and it calls the wizard rather than `init`. if (process.stdout.isTTY === true && process.stdin.isTTY === true) { - await runCommand(initCommand, { rawArgs }); + const result = await runWizard({ cwd: resolveCwd(rawArgs) }); + if (result.status === "cancelled") { + process.exitCode = 1; + } return; } console.error( "Taskless CLI — non-interactive context detected.\n" + " For interactive install, run from a terminal.\n" + - " For scripted install, run `taskless init --no-interactive`.\n" + + " For scripted install, run `taskless init`.\n" + " For agent recipes, run `taskless agent` (no args) for the topic index.\n" ); // Forward the parent's rawArgs (e.g. `-d `) so the agent command diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 88a198b7..b26d9bc0 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -158,6 +158,20 @@ export interface RecipeOptions { * @default true */ header?: boolean; + /** + * Add the fetch-time directive as the header block's second line: the + * text was resolved by the CLI when it was fetched, the next task fetches + * it again, and a session that saw an install or upgrade holds a stale + * skill until it reloads. The `agent` command asks for it, because what it + * serves IS a fetch. The prompts export does not, because a consumer + * embedding a recipe in its own prompt has no CLI to re-run, and the + * statement would be false there. + * + * Lives in the header block so `header: false` drops it with the version. + * + * @default false + */ + directive?: boolean; } /** @@ -320,12 +334,41 @@ function renderTemplate( options: RecipeOptions = {} ): string { const rendered = sprintf(template, buildVariables(template, topic, options)); - return options.header === false ? stripHeader(rendered) : rendered; + if (options.header === false) return stripHeader(rendered); + return options.directive === true + ? addDirective(rendered, resolveInvocation(options)) + : rendered; } /** Every recipe opens with this marker on its first line. */ const HEADER_PREFIX = "# Topic:"; +/** + * The fetch-time directive, identical for every topic except the invocation. + * Rendered from the same resolution `%(TASKLESS_CLI)s` uses, so the command + * an agent is told to re-run is the one that served it. + */ +export function fetchTimeDirective(invocation: string): string { + return ( + `Resolved by the CLI when you fetched it. Your next Taskless task, in this session or another, ` + + `fetches it again with \`${invocation} agent \`; do not reuse this copy. ` + + `If Taskless was installed or upgraded during this session, the skill in your context is stale until it is reloaded.` + ); +} + +/** + * Insert the directive as line 2 of the header block. Anchored to the first + * line like {@link stripHeader}, and a no-op on text that does not open with + * the header, so a malformed recipe is served as-is rather than gaining a + * directive above its first real line. + */ +function addDirective(content: string, invocation: string): string { + if (!content.startsWith(HEADER_PREFIX)) return content; + const firstBreak = content.indexOf("\n"); + if (firstBreak === -1) return `${content}\n${fetchTimeDirective(invocation)}`; + return `${content.slice(0, firstBreak)}\n${fetchTimeDirective(invocation)}${content.slice(firstBreak)}`; +} + /** * Drop the leading header block from rendered recipe text: the `# Topic: …` * line, the fetch-time directive beneath it, and the single blank line that diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index 77a6bd2f..49548e5d 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -145,6 +145,21 @@ describe("taskless agent ", () => { expect(result.stderr).not.toContain("Unknown command"); }); + it("serves every recipe under the fetch-time directive, and the update command too", async () => { + // What `agent` serves IS a fetch, so line 2 says: resolved now, fetch + // again next task, stale skill after an install. The recipe files do + // not carry it (see prompts.test.ts); the renderer adds it here. + for (const args of [["agent", "check"], ["agent", "init"], ["update"]]) { + const result = await runCli([...args, "-d", cwd]); + const lines = result.stdout.split("\n"); + expect(lines[0], args.join(" ")).toMatch(/^# Topic:/); + expect(lines[1], args.join(" ")).toContain("do not reuse this copy"); + expect(lines[1], args.join(" ")).toMatch(/agent `/); + expect(lines[1], args.join(" ")).toContain("stale"); + expect(lines[2], args.join(" ")).toBe(""); + } + }); + // D9: a reader who arrived at the wrong recipe should find that out in the // first line, where recovery is a re-decision, rather than after authoring // the wrong artifact. Fixed shape across all five so it is recognisable. diff --git a/packages/cli/test/anonymous-flag.test.ts b/packages/cli/test/anonymous-flag.test.ts index 67bcdd93..cb48a184 100644 --- a/packages/cli/test/anonymous-flag.test.ts +++ b/packages/cli/test/anonymous-flag.test.ts @@ -171,13 +171,7 @@ describe("--anonymous flag (per-command behavior matrix)", () => { describe("init --anonymous", () => { it("accepts the flag as no-op", async () => { - const result = await runCli([ - "init", - "--no-interactive", - "--anonymous", - "-d", - cwd, - ]); + const result = await runCli(["init", "--anonymous", "-d", cwd]); expect(result.exitCode).toBe(0); }); }); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 4fd36677..2fef2d28 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -27,7 +27,7 @@ async function exists(path: string): Promise { /** Run a real install in `cwd`, then rewrite the recorded version. */ async function installAtVersion(cwd: string, version: string): Promise { - await execFileAsync("node", [binPath, "init", "--no-interactive", "-d", cwd]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); const manifestPath = join(cwd, ".taskless", "taskless.json"); const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { install?: { cliVersion?: string }; @@ -36,7 +36,7 @@ async function installAtVersion(cwd: string, version: string): Promise { await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); } -describe("taskless init --no-interactive", () => { +describe("taskless init (the batch install)", () => { let cwd: string; beforeEach(async () => { @@ -53,7 +53,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -69,7 +68,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -86,7 +84,6 @@ describe("taskless init --no-interactive", () => { const { stdout, stderr } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -97,9 +94,10 @@ describe("taskless init --no-interactive", () => { expect(combined).not.toContain("Enter code:"); }); - it("auto-detects non-interactive context when no TTY and no flag", async () => { - // Invoking via execFile makes stdout not-a-TTY, which should trigger - // the auto-switch notice. + it("installs under a pipe with no notice about it", async () => { + // Invoking via execFile makes stdout not-a-TTY. `init` used to detect + // that and announce it was switching to the batch path; it IS the batch + // path now, so there is nothing to switch to and nothing to announce. await mkdir(join(cwd, ".claude"), { recursive: true }); const { stdout, stderr } = await execFileAsync("node", [ @@ -109,15 +107,34 @@ describe("taskless init --no-interactive", () => { cwd, ]); - expect(stderr).toContain("Detected non-interactive context"); + expect(stderr).not.toContain("non-interactive"); expect(stdout).toContain("Claude Code (.claude/)"); }); + it("treats a legacy --no-interactive flag as a no-op", async () => { + // The flag selected this path and is no longer defined. A script that + // still spells it out gets the same install, not an error. + await mkdir(join(cwd, ".claude"), { recursive: true }); + + const { stdout, stderr } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stderr).not.toContain("Unknown"); + expect(stdout).toContain("Claude Code (.claude/)"); + expect( + await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) + ).toBe(true); + }); + it("falls back to .agents/ when no tools are detected", async () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -146,13 +163,7 @@ describe("taskless init --no-interactive", () => { it("writes taskless.json with install state recorded", async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); - await execFileAsync("node", [ - binPath, - "init", - "--no-interactive", - "-d", - cwd, - ]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); const manifest = JSON.parse( await readFile(join(cwd, ".taskless", "taskless.json"), "utf8") @@ -170,7 +181,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -186,7 +196,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -207,7 +216,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -220,11 +228,14 @@ describe("taskless init --no-interactive", () => { expect(stdout).toContain("moved from 0.0.1-previous to"); expect(stdout).toMatch(/Run `.* update`/); + // Order: upgrade trailer, then the reload banner (the version moved, so + // it prints), then the onboarding line last. const lines = stdout.trimEnd().split("\n"); + const trailerAt = lines.findIndex((line) => line.includes("next commit")); + const reloadAt = lines.findIndex((line) => line.includes("Reload skills")); + expect(trailerAt).toBeGreaterThan(-1); + expect(reloadAt).toBeGreaterThan(trailerAt); expect(lines.at(-1)).toMatch(/^Next:/); - expect( - lines.indexOf(lines.find((line) => line.includes("next commit"))!) - ).toBeLessThan(lines.length - 1); }); it("omits the update pointer when the version did not move", async () => { @@ -233,19 +244,12 @@ describe("taskless init --no-interactive", () => { await installAtVersion(cwd, "0.0.1-previous"); // Rewrite at the previous version so the next run sees no move but has // to re-write the stubs it finds stale. - await execFileAsync("node", [ - binPath, - "init", - "--no-interactive", - "-d", - cwd, - ]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); await mkdir(join(cwd, ".claude"), { recursive: true }); const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -259,18 +263,11 @@ describe("taskless init --no-interactive", () => { it("prints no upgrade trailer when a re-install changed nothing", async () => { // A no-op has nothing to commit and nothing to reconcile. A trailer that // said so would teach an agent to skim it. - await execFileAsync("node", [ - binPath, - "init", - "--no-interactive", - "-d", - cwd, - ]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -311,7 +308,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -343,7 +339,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -358,7 +353,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -369,18 +363,11 @@ describe("taskless init --no-interactive", () => { it("stays quiet when the recorded version did not move", async () => { // The re-run case. A banner here would appear on every ordinary install // and train people to scroll past it. - await execFileAsync("node", [ - binPath, - "init", - "--no-interactive", - "-d", - cwd, - ]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 4330be9a..7474d2e6 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -95,7 +95,7 @@ describe("this repository's own installed Taskless docs", () => { ); // If this fails, the template changed and this project was not migrated. - // Run `pnpm build && pnpm cli init --no-interactive` and commit the result; + // 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(pinnedSpecifier())); diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index dac49776..24dea46f 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -118,7 +118,6 @@ describe("who migrates, and who refuses", () => { const { stdout } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -140,7 +139,6 @@ describe("who migrates, and who refuses", () => { const { stderr } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -158,7 +156,6 @@ describe("who migrates, and who refuses", () => { const { stdout } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -194,11 +191,10 @@ describe("who migrates, and who refuses", () => { it("init --json reports changed:false, a recorded version, and empty lists on a no-op re-install", async () => { await seedVersion3(); - await runCli(["init", "--no-interactive", "-d", temporaryDirectory]); + await runCli(["init", "-d", temporaryDirectory]); const { stdout } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -221,11 +217,10 @@ describe("who migrates, and who refuses", () => { it("init --json omits the field when nothing migrated", async () => { // Absence is the signal, so a consumer never reads empty arrays to decide. await seedVersion3(); - await runCli(["init", "--no-interactive", "-d", temporaryDirectory]); + await runCli(["init", "-d", temporaryDirectory]); const { stdout } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -268,12 +263,7 @@ describe("who migrates, and who refuses", () => { it("names the versions and the files on human stderr", async () => { await seedVersion3(); - const { stderr } = await runCli([ - "init", - "--no-interactive", - "-d", - temporaryDirectory, - ]); + const { stderr } = await runCli(["init", "-d", temporaryDirectory]); const span = `from schema version ${String(SEEDED_FROM)} to ${String(LATEST_SCHEMA_VERSION)}`; expect(stderr).toContain(`Migrating .taskless/ ${span}`); @@ -284,14 +274,9 @@ describe("who migrates, and who refuses", () => { it("says nothing on stderr when the scaffold is already current", async () => { await seedVersion3(); - await runCli(["init", "--no-interactive", "-d", temporaryDirectory]); + await runCli(["init", "-d", temporaryDirectory]); - const { stderr } = await runCli([ - "init", - "--no-interactive", - "-d", - temporaryDirectory, - ]); + const { stderr } = await runCli(["init", "-d", temporaryDirectory]); expect(stderr).not.toContain("Migrat"); }); diff --git a/packages/cli/test/mixed-engine-check.test.ts b/packages/cli/test/mixed-engine-check.test.ts index 48afe197..e16136d8 100644 --- a/packages/cli/test/mixed-engine-check.test.ts +++ b/packages/cli/test/mixed-engine-check.test.ts @@ -250,7 +250,7 @@ describe("check over a project with both engines", () => { // about. const scaffold = await mkdtemp(join(tmpdir(), "taskless-scaffold-")); try { - const init = await runCli(["init", "--no-interactive", "-d", scaffold]); + const init = await runCli(["init", "-d", scaffold]); expect(init.exitCode).toBe(0); // Author the rule the way a user would: one directory holding the @@ -301,7 +301,7 @@ describe("check over a project with both engines", () => { it("surfaces Vale's W101 when an assignment sits outside every matcher", async () => { const scaffold = await mkdtemp(join(tmpdir(), "taskless-w101-")); try { - const init = await runCli(["init", "--no-interactive", "-d", scaffold]); + const init = await runCli(["init", "-d", scaffold]); expect(init.exitCode).toBe(0); const rule = join(scaffold, ".taskless", "rules", "vale", "no-simply"); @@ -347,7 +347,7 @@ describe("check over a project with both engines", () => { it("still reports every other file's findings, end to end", async () => { const scaffold = await mkdtemp(join(tmpdir(), "taskless-parse-error-")); try { - const init = await runCli(["init", "--no-interactive", "-d", scaffold]); + const init = await runCli(["init", "-d", scaffold]); expect(init.exitCode).toBe(0); const rule = join(scaffold, ".taskless", "rules", "vale", "no-simply"); diff --git a/packages/cli/test/no-implicit-migration.test.ts b/packages/cli/test/no-implicit-migration.test.ts index c247eb6f..a25e96de 100644 --- a/packages/cli/test/no-implicit-migration.test.ts +++ b/packages/cli/test/no-implicit-migration.test.ts @@ -186,7 +186,7 @@ describe("a reporting command never migrates", () => { it("still runs against a project that is already current", async () => { // The refusal is about being BEHIND, not about having a scaffold. - await runCli(["init", "--no-interactive", "-d", directory]); + await runCli(["init", "-d", directory]); const { stderr, exitCode } = await runCli(["check", "-d", directory]); expect(stderr).not.toContain("schema version"); @@ -211,14 +211,6 @@ describe("a reporting command never migrates", () => { } }); - it("points a piped caller at init --no-interactive", async () => { - // Every run in this suite has a piped stdout, which is what an agent - // has. A bare `init` there prints the topic index and migrates nothing, - // so a refusal naming it would send the agent in a loop. - const { stderr } = await runCli(["check", "-d", directory]); - expect(stderr).toMatch(/Run `.* init --no-interactive` to migrate/); - }); - it("still explains the scaffold migration to a person", async () => { // Suppressed for machines, not removed. Without `--json` the summary is // the only thing telling someone their working tree just changed. @@ -246,13 +238,7 @@ describe("a reporting command never migrates", () => { it("migrates when asked, and reports what moved", async () => { // The other half of the trade: the migration still happens, on a command // whose job is to change the project. - const { stdout } = await runCli([ - "init", - "--no-interactive", - "--json", - "-d", - directory, - ]); + const { stdout } = await runCli(["init", "--json", "-d", directory]); const envelope = parseEnvelope<{ migrated?: { from: number; to: number }; @@ -327,12 +313,7 @@ describe("a manifest that cannot be parsed", () => { }); it("init refuses rather than rewriting what it could not parse", async () => { - const { exitCode } = await runCli([ - "init", - "--no-interactive", - "-d", - directory, - ]); + const { exitCode } = await runCli(["init", "-d", directory]); expect(exitCode).toBe(1); // Byte-for-byte. Measured before the fix, this file came back as @@ -347,13 +328,7 @@ describe("a manifest that cannot be parsed", () => { const bare = await mkdtemp(join(tmpdir(), "tskl-no-manifest-")); try { await mkdir(join(bare, ".taskless", "rules"), { recursive: true }); - const { stdout } = await runCli([ - "init", - "--no-interactive", - "--json", - "-d", - bare, - ]); + const { stdout } = await runCli(["init", "--json", "-d", bare]); const envelope = parseEnvelope<{ migrated?: { from: number; to: number }; }>(stdout); diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index c07ea17f..00f57615 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -76,26 +76,45 @@ describe("prompt rendering", () => { expect(getPrompt("create-sg-rule")).toContain(`CLI v${__VERSION__}`); }); - it("carries the fetch-time directive as line 2 of every recipe on disk", async () => { - // Byte-identical across recipes, including the `.anonymous` variants, so - // an agent sees one sentence rather than twenty paraphrases of it. Line 3 - // is blank because `stripHeader` ends the block at the first blank line: - // a recipe that ran the directive into its body would keep it in the - // header-less rendering. + it("keeps a one-line header in every recipe file on disk", async () => { + // The directive is added by the renderer on the `agent` command's + // request, never written into a file: a recipe read through the prompts + // export must not claim the reader can re-run a CLI. const allEntries = await readdir(recipeDirectory); const entries = allEntries.filter((name) => name.endsWith(".md")); - const directives = new Set(); + expect(entries.length).toBeGreaterThan(0); for (const name of entries) { const content = await readFile(join(recipeDirectory, name), "utf8"); const lines = content.split("\n"); expect(lines[0], `${name} line 1`).toMatch(/^# Topic:/); - expect(lines[1], `${name} line 2`).toContain("do not reuse this copy"); - expect(lines[1], `${name} line 2`).toContain("%(TASKLESS_CLI)s agent"); - expect(lines[2], `${name} line 3`).toBe(""); - directives.add(lines[1]!); + expect(lines[1], `${name} line 2`).toBe(""); + expect(content, name).not.toContain("do not reuse this copy"); } - expect(entries.length).toBeGreaterThan(0); - expect([...directives]).toHaveLength(1); + }); + + it("does not carry the directive through the prompts export", () => { + for (const topic of TOPICS) { + expect(getPrompt(topic), topic).not.toContain("do not reuse this copy"); + } + }); + + it("adds the directive as line 2 of the header block when asked", () => { + const plain = getRecipe("check", { invocation: "npx @taskless/cli" })!; + const served = getRecipe("check", { + directive: true, + invocation: "npx @taskless/cli", + })!; + const plainLines = plain.split("\n"); + const servedLines = served.split("\n"); + expect(servedLines[0]).toBe(plainLines[0]); + expect(servedLines[1]).toContain("do not reuse this copy"); + expect(servedLines[1]).toContain("`npx @taskless/cli agent `"); + expect(servedLines[1]).toContain("stale"); + expect(servedLines[2]).toBe(""); + // The body is untouched: everything after the header block matches. + expect(servedLines.slice(3).join("\n")).toBe( + plainLines.slice(2).join("\n") + ); }); it.each([ @@ -371,26 +390,30 @@ describe("host mechanics suppression", () => { }); describe("header suppression", () => { - it("drops the header block and the blank line after it, leaving the body intact", () => { + it("drops the header line and the blank line after it, leaving the body intact", () => { const withHeader = getPrompt("create-sg-rule"); const withoutHeader = getPrompt("create-sg-rule", { header: false }); expect(withHeader.startsWith("# Topic: create-sg-rule")).toBe(true); expect(withoutHeader.startsWith("# Topic:")).toBe(false); - // The block is the topic line, the fetch-time directive, and the blank - // line that closes it. The body is the same string minus those three. - expect(withoutHeader).toBe(withHeader.split("\n").slice(3).join("\n")); + // The body is the same string, minus the header line and its blank line. + expect(withoutHeader).toBe(withHeader.split("\n").slice(2).join("\n")); }); it("drops the fetch-time directive with the version", () => { // A header-less rendering is for a consumer embedding the text in its - // own prompt. "Re-run the CLI" is as wrong there as a version string. - for (const topic of TOPICS) { - expect( - getPrompt(topic, { header: false }), - `${topic} kept the directive` - ).not.toContain("do not reuse this copy"); - } + // own prompt. "Re-run the CLI" is as wrong there as a version string, so + // the block is stripped whole, however many lines it holds. + const served = getRecipe("check", { + directive: true, + invocation: "npx @taskless/cli", + header: false, + })!; + expect(served).not.toContain("do not reuse this copy"); + expect(served).not.toMatch(/CLI v\d/); + expect(served).toBe( + getRecipe("check", { header: false, invocation: "npx @taskless/cli" }) + ); }); it("leaves no CLI version string behind", () => { @@ -455,8 +478,12 @@ describe("agent command parity", () => { async (topic) => { const { stdout } = await execFileAsync("node", [binPath, "agent", topic]); // The command trims trailing whitespace before printing; console.log then - // adds the single newline that stdout carries. - expect(stdout.trimEnd()).toBe(getPrompt(topic).trimEnd()); + // adds the single newline that stdout carries. It also asks for the + // fetch-time directive, which the export leaves off by default; a + // consumer that wants the served text asks for it the same way. + expect(stdout.trimEnd()).toBe( + getPrompt(topic, { directive: true }).trimEnd() + ); } ); }); diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index b541fa1b..9938fbf4 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -124,7 +124,7 @@ describe("recording a rules reconciliation", () => { it("does not stamp a pre-existing project that never reconciled", async () => { // The silent skip this whole feature exists to prevent, reachable through - // setup rather than through the walk. `init --no-interactive` is the + // setup rather than through the walk. `init` is the // documented refresh path for an EXISTING project, and it runs after // `ensureTasklessDirectory` has already created the directory, so // "was this new" has to be sampled before that or it always reads new. @@ -136,7 +136,7 @@ describe("recording a rules reconciliation", () => { (JSON.parse(before.stdout) as { walk: unknown }).walk ).not.toBeNull(); - await runCli(["init", "--no-interactive", "-d", cwd]); + await runCli(["init", "-d", cwd]); const after = await runCli(["update", "--json", "-d", cwd]); const walk = ( diff --git a/packages/cli/test/runtime-fixture-runner.test.ts b/packages/cli/test/runtime-fixture-runner.test.ts index a25ba523..662f75fa 100644 --- a/packages/cli/test/runtime-fixture-runner.test.ts +++ b/packages/cli/test/runtime-fixture-runner.test.ts @@ -174,7 +174,7 @@ async function testRuleJson(...extra: string[]): Promise { beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "tskl-rt-runner-")); - await runCli(["init", "--no-interactive", "-d", cwd]); + await runCli(["init", "-d", cwd]); }); afterEach(async () => { diff --git a/packages/cli/test/schemas-export.test.ts b/packages/cli/test/schemas-export.test.ts index e4526b67..e7ef6b65 100644 --- a/packages/cli/test/schemas-export.test.ts +++ b/packages/cli/test/schemas-export.test.ts @@ -55,7 +55,7 @@ let cwd: string; beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "tskl-schemas-")); - await execFileAsync("node", [binPath, "init", "--no-interactive", "-d", cwd]); + await execFileAsync("node", [binPath, "init", "-d", cwd]); }); afterEach(async () => { diff --git a/packages/cli/test/verify-test-commands.test.ts b/packages/cli/test/verify-test-commands.test.ts index 2b4e1fca..20052747 100644 --- a/packages/cli/test/verify-test-commands.test.ts +++ b/packages/cli/test/verify-test-commands.test.ts @@ -40,7 +40,7 @@ interface Report { beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "tskl-verify-cmd-")); - await runCli(["init", "--no-interactive", "-d", cwd]); + await runCli(["init", "-d", cwd]); }); afterEach(async () => { diff --git a/packages/cli/test/verify.test.ts b/packages/cli/test/verify.test.ts index 1ea9d839..95e76d89 100644 --- a/packages/cli/test/verify.test.ts +++ b/packages/cli/test/verify.test.ts @@ -849,7 +849,7 @@ describe("test: the tested/failed/refused split on a mixed run (#284)", () => { beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "tskl-verify-mixed-")); - await runVerifyMixedRunCli(["init", "--no-interactive", "-d", cwd]); + await runVerifyMixedRunCli(["init", "-d", cwd]); }); afterEach(async () => { diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 8de38ef4..0f9a0ba7 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -243,7 +243,7 @@ describe("runWizard end-to-end", () => { * calls `readInstallState`, which is the first thing `runWizard` does inside * its frame. An unreadable manifest therefore throws between `intro()` and * `outro()`, and the refusal built for `check`, `verify` and - * `init --no-interactive` has to reach a person here too rather than escaping + * `init` has to reach a person here too rather than escaping * past a frame nothing closed. */ describe("runWizard with an unreadable manifest", () => { From 5b981e96b807382c8e0cb1b94939994a13b54f76 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 10 Sep 2026 16:56:59 -0700 Subject: [PATCH 3/4] fix(cli): spell the CLI as %(TASKLESS_CLI)s in the skill and command sources The canonical write searched the prose for the literal `npx @taskless/cli` and rewrote it for nightly/dev builds. That is whitespace-sensitive: a wrapped line or a doubled space escaped the rewrite silently. The sources now carry the recipes' token and `renderInvocationPlaceholder` substitutes it exactly at install; a source test forbids the literal. Re-archives the change with the added requirement. --- .changeset/init-upgrade-context.md | 2 +- commands/tskl/tskl.md | 26 +++++----- .../2026-09-10-init-upgrade-context/design.md | 4 ++ .../proposal.md | 2 +- .../specs/cli-init/spec.md | 25 +++++++++ .../specs/skill-taskless/spec.md | 7 +++ .../2026-09-10-init-upgrade-context/tasks.md | 1 + openspec/specs/cli-init/spec.md | 11 +++- openspec/specs/skill-taskless/spec.md | 7 +++ packages/cli/src/install/canonical.ts | 18 ++++--- packages/cli/src/util/invocation.ts | 22 ++++++++ packages/cli/test/apply-install-plan.test.ts | 8 ++- .../cli/test/installed-documentation.test.ts | 51 +++++++++++++++++++ skills/taskless/SKILL.md | 44 ++++++++-------- 14 files changed, 181 insertions(+), 47 deletions(-) diff --git a/.changeset/init-upgrade-context.md b/.changeset/init-upgrade-context.md index 4dfe0fb4..d57726a1 100644 --- a/.changeset/init-upgrade-context.md +++ b/.changeset/init-upgrade-context.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -`taskless init` is now the batch install in every context, and `--no-interactive` is dropped: a bare `npx @taskless/cli` in a terminal is the wizard, `init` is the install and upgrade path for agents, scripts, and CI, and `agent init` is the recipe. A script that still passes the flag gets `init` unchanged. `init` ends with an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written. The `agent` subcommand serves every recipe under a fetch-time directive (fetch again next task; a session that installed or upgraded Taskless holds a stale skill), which the `@taskless/cli/prompts` export does not carry and which `header: false` strips with the version. The `agent init` recipe is rewritten for the agent that runs it, and the skill and `tskl` command say a recipe is fetched again for each task. +`taskless init` is now the batch install in every context, and `--no-interactive` is dropped: a bare `npx @taskless/cli` in a terminal is the wizard, `init` is the install and upgrade path for agents, scripts, and CI, and `agent init` is the recipe. A script that still passes the flag gets `init` unchanged. `init` ends with an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written. The `agent` subcommand serves every recipe under a fetch-time directive (fetch again next task; a session that installed or upgraded Taskless holds a stale skill), which the `@taskless/cli/prompts` export does not carry and which `header: false` strips with the version. The `agent init` recipe is rewritten for the agent that runs it, and the skill and `tskl` command say a recipe is fetched again for each task. The skill and command sources name the CLI through the `%(TASKLESS_CLI)s` placeholder, rendered at install, so a nightly or dev build no longer depends on finding the literal `npx @taskless/cli` in prose. diff --git a/commands/tskl/tskl.md b/commands/tskl/tskl.md index 4dc729a1..1c9caac7 100644 --- a/commands/tskl/tskl.md +++ b/commands/tskl/tskl.md @@ -20,8 +20,8 @@ with Taskless before proceeding. Otherwise, follow the same flow as the `taskless` skill: 1. Identify the topic from `$ARGUMENTS` using the table below. -2. Fetch the canonical recipe with `npx @taskless/cli agent ` (or - `npx @taskless/cli agent --anonymous` if the user is offline or +2. Fetch the canonical recipe with `%(TASKLESS_CLI)s agent ` (or + `%(TASKLESS_CLI)s agent --anonymous` if the user is offline or explicitly asked for anonymous mode). 3. Follow the recipe step-by-step. The recipe is canonical for the currently-installed CLI version; do not improvise from prior knowledge. @@ -32,15 +32,15 @@ Otherwise, follow the same flow as the `taskless` skill: ## Topics -| User wants | Topic | -| -------------------------- | -------------------------------------- | -| Update Taskless skills | run `npx @taskless/cli update` | -| Create a new rule | `npx @taskless/cli agent route` | -| Improve an existing rule | `npx @taskless/cli agent improve-rule` | -| Delete a rule | `npx @taskless/cli agent delete-rule` | -| Check code against rules | `npx @taskless/cli agent check` | -| Log in, log out, or status | `npx @taskless/cli agent auth` | -| Wire into CI | `npx @taskless/cli agent ci` | - -If unsure, run `npx @taskless/cli agent` (no args) for the topic +| User wants | Topic | +| -------------------------- | ------------------------------------- | +| Update Taskless skills | run `%(TASKLESS_CLI)s update` | +| Create a new rule | `%(TASKLESS_CLI)s agent route` | +| Improve an existing rule | `%(TASKLESS_CLI)s agent improve-rule` | +| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | +| Check code against rules | `%(TASKLESS_CLI)s agent check` | +| Log in, log out, or status | `%(TASKLESS_CLI)s agent auth` | +| Wire into CI | `%(TASKLESS_CLI)s agent ci` | + +If unsure, run `%(TASKLESS_CLI)s agent` (no args) for the topic disambiguation table. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md index c8aac1cc..b114a7a3 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md @@ -48,6 +48,10 @@ Twenty recipe files carrying an identical line invited twenty paraphrases, and t Living in the header block means `stripHeader` drops it with the version: the function now strips through the first blank line rather than a fixed line count, with the same first-line `# Topic:` anchor so a header inside a fenced example is left alone. Parity between the export and the command is stated modulo that option, and a consumer that passes `directive: true` gets exactly what the command prints. +### The skill and command name the CLI through a placeholder + +The canonical write used to find the literal `npx @taskless/cli` in the skill and command prose and rewrite it to the build's invocation. That search is whitespace-sensitive: a wrapped line or a doubled space left the literal in place, and a nightly install then carried a skill telling its agent to run the release package, with nothing failing. The sources now spell the invocation as `%(TASKLESS_CLI)s`, the recipes' token, and `renderInvocationPlaceholder` substitutes it exactly at install. It is an exact-token replacement rather than a sprintf render, since the sources are prose with no other placeholders and sprintf would turn a literal `%` in a markdown body into a render error. A source test asserts the literal is absent and the token present, and an install test asserts the rendered file carries the build's invocation and no unrendered `%(`. The token is the same width as `npx @taskless/cli`, so the topic tables stay aligned in source and in a release install. `applyCliInvocation` stays for one-line code strings, where an exact literal is not brittle. + ### The skill directive is a section, budgeted against the cap The body is 72 lines against an 80-line cap. The directive is a heading and five lines. The `tskl` command has no cap and gets the same paragraph. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md index 10b3afd7..df5324b2 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md @@ -11,7 +11,7 @@ Both gaps are in agent-facing text and output shape that ship in the bundle, so - A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written, so a no-op re-install reads as one. - The `agent init` recipe is rewritten for its actual reader. Today it says "the user runs this themselves"; its most common caller is an agent that `check` just sent there. It describes `init`, the envelope, and what follows an install: tell the user which paths need committing (the agent does not run git), run `update` after a version move, and treat a session that predates the install as holding stale skills. - The `agent` subcommand serves every recipe under a fetch-time directive, added by the renderer as the header block's second line: the text was resolved at fetch time, the next task fetches it again, and a session that installed or upgraded Taskless holds a stale skill until reloaded. The recipe files do not carry it, and neither does the `@taskless/cli/prompts` export, whose consumer has no CLI to re-run. `header: false` strips the block whole. -- The skill body and the `tskl` command carry the same directive: fetch the recipe for every Taskless task, even one already fetched earlier in the session. +- The skill body and the `tskl` command carry the same directive: fetch the recipe for every Taskless task, even one already fetched earlier in the session. Both sources spell the CLI as `%(TASKLESS_CLI)s`, rendered at install to the build's invocation, in place of a whitespace-sensitive search for the literal `npx @taskless/cli` in prose. Nothing here is **BREAKING**. `init` without a TTY already ran the batch install; with a TTY it ran the wizard, and that spelling now runs the batch install too, which is the one behaviour change a person can notice. The `init --json` envelope only gains fields; the human output only gains lines before an existing final line; recipe bodies are unchanged. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md index fc57a131..5558224d 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md @@ -127,3 +127,28 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di - **WHEN** `taskless update` processes its targets - **THEN** it SHALL NOT delete `.taskless/skills/` or `.taskless/commands/` as part of cleaning up any target - **AND** the canonical content SHALL remain readable throughout the update + +### 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 and command sources 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 their bodies. 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. + +#### 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` +- **AND** no frontmatter fields SHALL be modified at install time + +#### Scenario: The sources carry the placeholder, not the literal + +- **WHEN** the skill and command sources under `skills/` and `commands/` are read +- **THEN** each 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 diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md index f7c12422..75bc21c9 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md @@ -15,6 +15,7 @@ The consolidated skill body SHALL NOT contain step-by-step instructions for any - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. 7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. The `tskl` command body SHALL carry the same statement. +8. Names the CLI through the `%(TASKLESS_CLI)s` placeholder everywhere it spells an invocation, never as the literal `npx @taskless/cli`. The install renders the placeholder to the build's invocation, so a nightly or path-form build serves a skill that names itself. The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) @@ -23,6 +24,12 @@ The body SHALL be no more than 80 lines of markdown to keep the always-loaded su - **WHEN** the skill body is read by an agent - **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" +#### Scenario: Skill body spells the invocation as a placeholder + +- **WHEN** the skill body or the `tskl` command body is read from source +- **THEN** every CLI invocation SHALL be written as `%(TASKLESS_CLI)s …` +- **AND** the body SHALL NOT contain the literal `npx @taskless/cli` + #### Scenario: Skill body forbids reusing a fetched recipe - **WHEN** the skill body or the `tskl` command body is read by an agent diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md index 769f8d55..ea3b33b6 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md @@ -35,6 +35,7 @@ splitting would separate a change from the test that pins it. - [x] 5.1 Add a short section to `skills/taskless/SKILL.md` stating a recipe is resolved at fetch time and each task fetches again, even for a topic already fetched in the session. Stay under the 80-line cap - [x] 5.2 Add the same statement to `commands/tskl/tskl.md` - [x] 5.3 Test through the installed documentation suite that both bodies contain the statement +- [x] 5.4 Replace the literal `npx @taskless/cli` in both bodies with `%(TASKLESS_CLI)s`; add `renderInvocationPlaceholder` and use it for canonical writes in place of the prose search; test that the sources carry the token and not the literal, and that an install renders it to the build's invocation ## 6. Ship diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index ebbfb420..fdc1672a 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -273,12 +273,21 @@ 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 and command sources 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 their bodies. 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. + #### 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/` +- **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` - **AND** no frontmatter fields SHALL be modified at install time +#### Scenario: The sources carry the placeholder, not the literal + +- **WHEN** the skill and command sources under `skills/` and `commands/` are read +- **THEN** each 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 diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index cfdc4f04..df1e6618 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -74,6 +74,7 @@ The consolidated skill body SHALL NOT contain step-by-step instructions for any - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. 7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. The `tskl` command body SHALL carry the same statement. +8. Names the CLI through the `%(TASKLESS_CLI)s` placeholder everywhere it spells an invocation, never as the literal `npx @taskless/cli`. The install renders the placeholder to the build's invocation, so a nightly or path-form build serves a skill that names itself. The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) @@ -82,6 +83,12 @@ The body SHALL be no more than 80 lines of markdown to keep the always-loaded su - **WHEN** the skill body is read by an agent - **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" +#### Scenario: Skill body spells the invocation as a placeholder + +- **WHEN** the skill body or the `tskl` command body is read from source +- **THEN** every CLI invocation SHALL be written as `%(TASKLESS_CLI)s …` +- **AND** the body SHALL NOT contain the literal `npx @taskless/cli` + #### Scenario: Skill body forbids reusing a fetched recipe - **WHEN** the skill body or the `tskl` command body is read by an agent diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index b9243be8..6e97d877 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -6,6 +6,7 @@ import { stringify } from "yaml"; import { applyCliInvocation, PROD_INVOCATION, + renderInvocationPlaceholder, withCliBuildNotice, } from "../util/invocation"; import { parseFrontmatter } from "./frontmatter"; @@ -50,9 +51,10 @@ export function canonicalCommandPath(filename: 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; content is emitted as-is for prod builds. For `dev`/`self` builds - * the CLI invocation is rewritten and a build notice prepended (see - * {@link applyCliInvocation} / {@link withCliBuildNotice}); prod is unchanged. + * of truth. The source spells the CLI as `%(TASKLESS_CLI)s`, rendered here to + * this build's invocation; for `dev`/`self`/nightly builds a build notice is + * also prepended (see {@link renderInvocationPlaceholder} / + * {@link withCliBuildNotice}). */ export async function writeCanonicalSkill( cwd: string, @@ -64,7 +66,7 @@ export async function writeCanonicalSkill( const path = join(directory, "SKILL.md"); const changed = await writeIfChanged( path, - withCliBuildNotice(applyCliInvocation(content)) + withCliBuildNotice(renderInvocationPlaceholder(content)) ); return { path, changed }; } @@ -96,7 +98,8 @@ async function writeIfChanged(path: string, content: string): Promise { * Write a command's full content to the canonical store at * `.taskless/commands/tskl/`. Emitted as-is for prod builds; for * `dev`/`self` builds the CLI invocation is rewritten and a build notice - * prepended (see {@link applyCliInvocation} / {@link withCliBuildNotice}). + * prepended (see {@link renderInvocationPlaceholder} / + * {@link withCliBuildNotice}). */ export async function writeCanonicalCommand( cwd: string, @@ -108,7 +111,7 @@ export async function writeCanonicalCommand( const path = join(directory, filename); const changed = await writeIfChanged( path, - withCliBuildNotice(applyCliInvocation(content)) + withCliBuildNotice(renderInvocationPlaceholder(content)) ); return { path, changed }; } @@ -139,7 +142,8 @@ const PROD_RESTORE_COMMAND = `${PROD_INVOCATION} init`; * The command a reader runs to restore a canonical file that is not on disk. * * Written in the published `npx @taskless/cli` form and rewritten by - * {@link applyCliInvocation}, exactly as canonical content is. A stub that + * {@link applyCliInvocation}. This is a one-line code string, so the literal + * rewrite that is too brittle for prose is exact here. A stub that * hardcoded the released package would tell someone running a `self` build to * fetch a different binary than the one that wrote the stub, and would tell a * nightly user to install over their nightly. diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index 85cffdda..78da1c68 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -28,6 +28,28 @@ export function buildInvocation(): string { return __TASKLESS_CLI__; } +/** The token skill and command sources spell the CLI invocation with. */ +export const INVOCATION_PLACEHOLDER = "%(TASKLESS_CLI)s"; + +/** + * Render {@link INVOCATION_PLACEHOLDER} in an installed skill or command body + * to this build's invocation. + * + * 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 + * sprintf: the sources are prose with no other placeholders, and sprintf + * would make a literal `%` in a markdown body a render error. + * + * This replaces searching the prose for the literal `npx @taskless/cli` + * ({@link applyCliInvocation}), which is whitespace-sensitive: a wrapped line + * or a doubled space left the literal in place, and a nightly install then + * 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__); +} + /** * Rewrite the canonical `npx @taskless/cli` invocation to the build-target * invocation (`__TASKLESS_CLI__`). diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index 0056f2ef..ea5f8b8a 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -18,6 +18,7 @@ import { getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; +import { renderInvocationPlaceholder } from "../src/util/invocation"; import { isShimStub, stubRecoveryInvocation } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; import { readInstallState, writeInstallState } from "../src/install/state"; @@ -66,12 +67,15 @@ describe("applyInstallPlan", () => { const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - // Canonical store holds verbatim content. + // Canonical store holds the source with `%(TASKLESS_CLI)s` rendered to + // this build's invocation, which is the only substitution it makes. const canonical = await readFile( join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), "utf8" ); - expect(canonical).toBe(tasklessSkill().content); + expect(canonical).toBe( + renderInvocationPlaceholder(tasklessSkill().content) + ); // The .claude target holds a delegating stub, not the full content. const stub = await readFile( diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 7474d2e6..8e0832a7 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -8,6 +8,13 @@ import { buildReadmeContent } from "../src/filesystem/migrations/0001-init"; import { pinnedSpecifier } from "../src/util/package-manager"; import { ensureTasklessDirectory } from "../src/filesystem/directory"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedCommands, + getEmbeddedSkills, +} from "../src/install/install"; +import { buildInvocation } from "../src/util/invocation"; import { ENGINES, RULES_DIRECTORY, @@ -113,6 +120,50 @@ describe("this repository's own installed Taskless docs", () => { expect(onDisk).not.toContain("sg/rules/"); }); + it("spells the CLI as %(TASKLESS_CLI)s in the skill and command sources, never the literal", async () => { + // The canonical write used to find `npx @taskless/cli` in the prose and + // rewrite it for nightly/dev builds. A literal is whitespace-sensitive: a + // wrapped line or a doubled space escaped the rewrite silently, and a + // nightly install then told its agent to run the release package. The + // token either matches exactly or this test says it is missing. + for (const [name, path] of [ + ["SKILL.md", resolve(repositoryRoot, "skills", "taskless", "SKILL.md")], + ["tskl.md", resolve(repositoryRoot, "commands", "tskl", "tskl.md")], + ] as const) { + const source = await readFile(path, "utf8"); + const body = source.replace(/^---\n[\s\S]*?\n---\n/, ""); + expect(body, `${name} names the CLI`).toContain("%(TASKLESS_CLI)s"); + expect(body, `${name} carries the literal`).not.toContain( + "npx @taskless/cli" + ); + } + }); + + it("renders the placeholder to this build's invocation at install, and nothing else", async () => { + const cwd = await mkdtemp(join(tmpdir(), "taskless-placeholder-")); + try { + await applyInstallPlan( + cwd, + buildInstallPlan( + [".claude"], + getEmbeddedSkills(), + getEmbeddedCommands() + ), + { cliVersion: "0.0.0-test" } + ); + for (const relative of [ + join(".taskless", "skills", "taskless", "SKILL.md"), + join(".taskless", "commands", "tskl", "tskl.md"), + ]) { + const installed = await readFile(join(cwd, relative), "utf8"); + expect(installed, relative).toContain(`${buildInvocation()} agent`); + expect(installed, relative).not.toContain("%("); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it("tells an agent a recipe is per-task, in the skill and the command", async () => { // A recipe is resolved at fetch time. Without this an agent that fetched // `agent check` once reuses the text for every later task in the session, diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 308f464f..b611bdf8 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -28,14 +28,14 @@ compatibility: Designed for Agents implementing the Agent Skills specification. # Taskless You do NOT have the steps for any Taskless action in your context. The current -canonical recipes live behind `npx @taskless/cli agent `. Always fetch +canonical recipes live behind `%(TASKLESS_CLI)s agent `. Always fetch the recipe first; do not improvise from prior knowledge — recipes change with each CLI version. ## Authoring a rule: always start at route For any request to add/write/create a rule — whether or not the user names a -tool (eslint, ruff, biome, stylelint, ast-grep) — fetch `npx @taskless/cli agent route` +tool (eslint, ruff, biome, stylelint, ast-grep) — fetch `%(TASKLESS_CLI)s agent route` and follow it. Do NOT fetch a `create-*-rule` topic directly, and do NOT author from your own linter knowledge. `route` runs `detect`, reasons about the request, and decides whether the rule is built in a linter the repo already uses @@ -50,27 +50,27 @@ authoring judgment lives in the fetched recipes. install. If routing lands on a local Taskless rule (`create-sg-rule`) or the service (`create-remote-rule`) and the working directory has no `.taskless/` directory, offer to run -`npx @taskless/cli` to install. If the user only wanted help with their own +`%(TASKLESS_CLI)s` to install. If the user only wanted help with their own linter, the `create-legacy-rule` path needs nothing installed. ## Topics -| User wants | Topic | -| -------------------------- | -------------------------------------- | -| Author/create a rule | `npx @taskless/cli agent route` | -| First-time install | tell user to run `npx @taskless/cli` | -| Update an existing install | tell user to run `npx @taskless/cli` | -| Fix rules after an upgrade | `npx @taskless/cli update` | -| Discover candidate rules | `npx @taskless/cli agent onboard` | -| Improve an existing rule | `npx @taskless/cli agent improve-rule` | -| Delete a rule | `npx @taskless/cli agent delete-rule` | -| Check code against rules | `npx @taskless/cli agent check` | -| Log in, log out, or status | `npx @taskless/cli agent auth` | -| Wire into CI | `npx @taskless/cli agent ci` | - -Two of those rows look alike and are not. Running `npx @taskless/cli` +| User wants | Topic | +| -------------------------- | ------------------------------------- | +| Author/create a rule | `%(TASKLESS_CLI)s agent route` | +| First-time install | tell user to run `%(TASKLESS_CLI)s` | +| Update an existing install | tell user to run `%(TASKLESS_CLI)s` | +| Fix rules after an upgrade | `%(TASKLESS_CLI)s update` | +| Discover candidate rules | `%(TASKLESS_CLI)s agent onboard` | +| Improve an existing rule | `%(TASKLESS_CLI)s agent improve-rule` | +| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | +| Check code against rules | `%(TASKLESS_CLI)s agent check` | +| Log in, log out, or status | `%(TASKLESS_CLI)s agent auth` | +| Wire into CI | `%(TASKLESS_CLI)s agent ci` | + +Two of those rows look alike and are not. Running `%(TASKLESS_CLI)s` migrates the `.taskless/` layout and refreshes the installed skills: that is -the DIRECTORY, and it is automatic. `npx @taskless/cli update` is about the +the DIRECTORY, and it is automatic. `%(TASKLESS_CLI)s update` is about the RULES already in the project, which no migration can rewrite for you, since a rewriter that now needs a `fix` or a rule whose matching semantics shifted under a new engine is a question about content rather than layout. @@ -80,7 +80,7 @@ reasonably conclude the upgrade is finished. The directory is migrated; the rules may still need work, and `update` is how to find out. If the user's intent is ambiguous between two topics, run -`npx @taskless/cli agent` (no args) to see the disambiguation table, or ask +`%(TASKLESS_CLI)s agent` (no args) to see the disambiguation table, or ask the user. ## Fetch the recipe for every task @@ -88,7 +88,7 @@ the user. A recipe is resolved when it is fetched: the CLI reads the installed version, the auth state, and the project layout at that moment. A recipe you fetched earlier in this session is not a substitute for fetching it again, even for -the same topic. Start each Taskless task with `npx @taskless/cli agent ` +the same topic. Start each Taskless task with `%(TASKLESS_CLI)s agent ` and follow the copy it returns. ## --anonymous @@ -96,11 +96,11 @@ and follow the copy it returns. Any rule/check command accepts `--anonymous` to skip the Taskless API and use local-only behavior. When the user is offline OR explicitly asks for anonymous mode, fetch the recipe with -`npx @taskless/cli agent --anonymous`, which returns the local-only +`%(TASKLESS_CLI)s agent --anonymous`, which returns the local-only flow (when one exists for that topic). ## First-run latency -The first invocation of `npx @taskless/cli` on a machine pays an npm +The first invocation of `%(TASKLESS_CLI)s` on a machine pays an npm cold-fetch (~5–15 seconds). This is normal — do not report it as a timeout or failure. Subsequent invocations are cached and fast. From 74f65a6de010b04ea4444fc58889d6ac00c87468 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 10 Sep 2026 17:22:09 -0700 Subject: [PATCH 4/4] fix(cli): keep the CI guard on the wizard, and name the real launcher in the upgrade trailer Dropping --no-interactive moved the wizard launch into index.ts and left behind the CI check init used to apply: a pseudo-terminal in CI would have launched the wizard into a job nobody is watching. The guard is now a pure shouldLaunchWizard with its own tests. The upgrade trailer's update pointer uses getCliPrefix(), the convention for messages a person reads, rather than the build's fixed invocation. --- .../2026-09-10-init-upgrade-context/design.md | 6 +++- .../specs/cli-init/spec.md | 8 ++++- openspec/specs/cli-init/spec.md | 8 ++++- packages/cli/src/commands/init.ts | 7 ++-- packages/cli/src/index.ts | 13 +++++-- packages/cli/src/util/interactive.ts | 22 ++++++++++++ packages/cli/test/interactive.test.ts | 36 +++++++++++++++++++ 7 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/util/interactive.ts create mode 100644 packages/cli/test/interactive.test.ts diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md index b114a7a3..ad55d353 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md @@ -22,12 +22,16 @@ ### `init` is always the batch install, and `--no-interactive` is gone -The flag existed so that a TTY could still get the batch path. With the wizard reachable only from a bare invocation, `init` has one behaviour in every context and the flag has nothing left to select. It is removed from the command definition rather than kept as a documented no-op: citty passes an undefined flag through, so a script that still spells it out gets `init` unchanged, and there is no second flag to explain. The bare TTY invocation in `index.ts` calls `runWizard` directly instead of delegating to `initCommand`. +The flag existed so that a TTY could still get the batch path. With the wizard reachable only from a bare invocation, `init` has one behaviour in every context and the flag has nothing left to select. It is removed from the command definition rather than kept as a documented no-op: citty passes an undefined flag through, so a script that still spells it out gets `init` unchanged, and there is no second flag to explain. The bare TTY invocation in `index.ts` calls `runWizard` directly instead of delegating to `initCommand`. The `CI` guard `init` used to carry moves with it, as a pure `shouldLaunchWizard` so it can be tested: a spawned CLI is never on a TTY, which is exactly the case the guard exists for (a pseudo-terminal in CI with nobody behind it). ### The upgrade trailer prints first, directly after the summary A reload, and anything the onboarding line proposes, come after the upgrade is understood: a reload is required before onboarding can use the new skill, and the commit obligation exists whether or not the user onboards. So the trailer is the first of the trailing notices, before the reload banner. The onboarding trailer stays the final line, which the existing requirement and its six scenarios pin. +### The trailer's `update` pointer names the launcher the person used + +`getCliPrefix()`, not `buildInvocation()`: a message a person reads follows the convention every other remedy in the CLI uses (`auth login`, `rule delete`), which detects the real launcher and falls back to `npx`. The migration refusal in `migrate.ts` predates that convention and still uses the build's fixed spelling; it is left alone here as out of scope. + ### Changed directories are named, not files The install result reports skill and command names per target, not paths, and deriving paths would re-implement the layout the install module already knows. The trailer names target directories (`.claude/`, `.taskless/`) plus `.taskless/` when a migration ran or the version moved, which is what a `git add` needs. Migration file paths are already listed in the migration notice, and on the envelope under `migrated.files`. diff --git a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md index 5558224d..b4abdc4d 100644 --- a/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md @@ -84,13 +84,19 @@ The `--anonymous` flag is accepted on `init` as a no-op (init does not call the ### Requirement: Bare taskless invocation launches the init wizard -The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND a TTY is attached. When stdout is NOT a TTY, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). +The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND both stdout and stdin are TTYs AND `CI` is not `true` or `1`. The `CI` check outranks the TTYs: some automated environments allocate a pseudo-terminal on both streams, and a wizard launched there waits for input nobody will give. When any of those conditions fails, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). #### Scenario: Bare taskless in a TTY launches the wizard - **WHEN** a user runs `taskless` with no subcommand and stdout is a TTY - **THEN** the CLI SHALL launch the interactive wizard +#### Scenario: Bare taskless under CI does not launch the wizard + +- **WHEN** `taskless` is invoked with no subcommand, both streams report a TTY, and `CI` is `true` or `1` +- **THEN** the CLI SHALL NOT launch the wizard +- **AND** SHALL take the non-TTY path below + #### Scenario: Bare taskless without a TTY prints preamble + agent topic index - **WHEN** `taskless` is invoked with no subcommand and stdout is not a TTY diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index fdc1672a..98aab139 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -365,13 +365,19 @@ The `init` subcommand SHALL use the resolved working directory from the global ` ### Requirement: Bare taskless invocation launches the init wizard -The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND a TTY is attached. When stdout is NOT a TTY, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). +The CLI entry point SHALL launch the interactive install wizard when invoked with no positional subcommand AND both stdout and stdin are TTYs AND `CI` is not `true` or `1`. The `CI` check outranks the TTYs: some automated environments allocate a pseudo-terminal on both streams, and a wizard launched there waits for input nobody will give. When any of those conditions fails, bare `taskless` SHALL print a non-interactive preamble explaining the context, followed by the agent topic index (instead of attempting the wizard or printing only top-level help). #### Scenario: Bare taskless in a TTY launches the wizard - **WHEN** a user runs `taskless` with no subcommand and stdout is a TTY - **THEN** the CLI SHALL launch the interactive wizard +#### Scenario: Bare taskless under CI does not launch the wizard + +- **WHEN** `taskless` is invoked with no subcommand, both streams report a TTY, and `CI` is `true` or `1` +- **THEN** the CLI SHALL NOT launch the wizard +- **AND** SHALL take the non-TTY path below + #### Scenario: Bare taskless without a TTY prints preamble + agent topic index - **WHEN** `taskless` is invoked with no subcommand and stdout is not a TTY diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 80010c1f..748df729 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -23,6 +23,7 @@ import { getOnboardTrailer } from "./onboard"; import { getRecipe } from "../prompts/recipes"; import { detectCliInvocation, + getCliPrefix, processLauncherContext, } from "../util/package-manager"; import { @@ -35,7 +36,6 @@ import { readManifest } from "../filesystem/migrate"; import type { MigrationReport } from "../filesystem/migrate"; import { TASKLESS_DIRECTORY } from "../rules/vale/formats"; import { CLIError } from "../util/cli-error"; -import { buildInvocation } from "../util/invocation"; import { makeErrorEnvelope } from "../types/errors"; export const initCommand = defineCommand({ @@ -110,7 +110,10 @@ export const initCommand = defineCommand({ migrated: result.migrated !== undefined, previousCliVersion: result.previousCliVersion, cliVersion: result.cliVersion, - invocation: buildInvocation(), + // A message a person reads, so the launcher they used, not the + // build's fixed spelling: the same convention as every other remedy + // (`auth login`, `rule delete`). + invocation: getCliPrefix(), }); if (upgradeTrailer !== undefined) { console.log(upgradeTrailer); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ff541be8..b928280f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ import { import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; import { runWizard } from "./wizard"; import { DIR_FLAGS, hasHelpFlag, splitRawArguments } from "./util/argv"; +import { shouldLaunchWizard } from "./util/interactive"; import { showResolvedUsage } from "./util/help"; import { CLIError } from "./util/cli-error"; @@ -98,8 +99,16 @@ const main = defineCommand({ // // The wizard is reached from HERE and nowhere else. `init` is the batch // path in every context, so a bare invocation is the only spelling that - // prompts, and it calls the wizard rather than `init`. - if (process.stdout.isTTY === true && process.stdin.isTTY === true) { + // prompts, and it calls the wizard rather than `init`. The guard carries + // the `CI` check `init` used to apply, since a pseudo-terminal in CI is + // still nobody to answer a prompt. + if ( + shouldLaunchWizard({ + stdoutIsTTY: process.stdout.isTTY, + stdinIsTTY: process.stdin.isTTY, + ci: process.env.CI, + }) + ) { const result = await runWizard({ cwd: resolveCwd(rawArgs) }); if (result.status === "cancelled") { process.exitCode = 1; diff --git a/packages/cli/src/util/interactive.ts b/packages/cli/src/util/interactive.ts new file mode 100644 index 00000000..4a062c86 --- /dev/null +++ b/packages/cli/src/util/interactive.ts @@ -0,0 +1,22 @@ +/** + * Whether a bare invocation should launch the interactive wizard. + * + * Both streams must be TTYs: clack reads from stdin, so a piped stdin would + * hang the wizard even when stdout is a terminal. And `CI` wins over the + * TTYs. Some automated environments allocate a pseudo-terminal on both + * streams (`docker run -it`, pty-allocating runners), so TTY detection alone + * would launch the wizard into a job nobody is watching, and it would wait + * there for input that never comes. The batch path, `init`, is what such a + * job wants, and the non-TTY preamble is what says so. + * + * Pure so it can be tested: a spawned CLI is never on a TTY, which is exactly + * the case this guard does not decide. + */ +export function shouldLaunchWizard(input: { + stdoutIsTTY: boolean | undefined; + stdinIsTTY: boolean | undefined; + ci: string | undefined; +}): boolean { + if (input.ci === "true" || input.ci === "1") return false; + return input.stdoutIsTTY === true && input.stdinIsTTY === true; +} diff --git a/packages/cli/test/interactive.test.ts b/packages/cli/test/interactive.test.ts new file mode 100644 index 00000000..08e2aec6 --- /dev/null +++ b/packages/cli/test/interactive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { shouldLaunchWizard } from "../src/util/interactive"; + +describe("shouldLaunchWizard", () => { + const tty = { stdoutIsTTY: true, stdinIsTTY: true }; + + it("launches on a terminal with both streams attached", () => { + expect(shouldLaunchWizard({ ...tty, ci: undefined })).toBe(true); + }); + + it.each([ + ["stdout piped", { stdoutIsTTY: false, stdinIsTTY: true }], + ["stdin piped", { stdoutIsTTY: true, stdinIsTTY: false }], + ["neither known", { stdoutIsTTY: undefined, stdinIsTTY: undefined }], + ])("refuses when %s", (_, streams) => { + expect(shouldLaunchWizard({ ...streams, ci: undefined })).toBe(false); + }); + + it.each(["true", "1"])( + "refuses under CI=%s even on a pseudo-terminal", + (ci) => { + // `docker run -it` and pty-allocating runners report a TTY on both + // streams with nobody behind them. This is the guard `init` used to + // carry, and the one a spawned-CLI test can never exercise. + expect(shouldLaunchWizard({ ...tty, ci })).toBe(false); + } + ); + + it.each(["", "0", "false", "yes"])("does not treat CI=%j as CI", (ci) => { + // Narrow on purpose, and narrower than the telemetry classifier: that + // one asks what a run IS, this one decides whether to prompt, and they + // are allowed to disagree. Only the two spellings the old guard read. + expect(shouldLaunchWizard({ ...tty, ci })).toBe(true); + }); +});