diff --git a/.changeset/init-upgrade-context.md b/.changeset/init-upgrade-context.md new file mode 100644 index 00000000..d57726a1 --- /dev/null +++ b/.changeset/init-upgrade-context.md @@ -0,0 +1,5 @@ +--- +"@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. 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 5fdbb98b..1c9caac7 100644 --- a/commands/tskl/tskl.md +++ b/commands/tskl/tskl.md @@ -20,23 +20,27 @@ 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. +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 -| 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/.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..ad55d353 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/design.md @@ -0,0 +1,67 @@ +## Context + +`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 `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, 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 + +### `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 `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`. + +### 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 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. + +### A version move is a change; an identical canonical write is not + +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 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 }`. + +### The directive is rendered on the `agent` command's request, not written into recipe files + +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. + +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. + +## 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. +- **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..df5324b2 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/proposal.md @@ -0,0 +1,41 @@ +## Why + +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` 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. 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. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `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` 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. + +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..919863c5 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-agent/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### 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/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..b4abdc4d --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-init/spec.md @@ -0,0 +1,160 @@ +## ADDED Requirements + +### Requirement: Init reports what an upgrade changed and what follows + +`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, 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; +- `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` 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 + +- **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` 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 --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` + +## MODIFIED Requirements + +### 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, 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. + +The `--anonymous` flag is accepted on `init` as a no-op (init does not call the Taskless API directly). + +#### Scenario: Running taskless init installs the consolidated skill + +- **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 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 +- **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 + +`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 + +### 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/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..e9cd3754 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,11 @@ +## MODIFIED Requirements + +### Requirement: The export and the agent command share one source and one renderer + +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", { 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/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..75bc21c9 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/specs/skill-taskless/spec.md @@ -0,0 +1,65 @@ +## 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. +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.) + +#### 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 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 +- **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..ea3b33b6 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-init-upgrade-context/tasks.md @@ -0,0 +1,44 @@ +# 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. `init` is the batch path; the bare invocation is the wizard + +- [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. Init reports the upgrade + +- [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 `agent init` recipe addresses the agent + +- [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. The `agent` subcommand serves recipes under the directive + +- [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 + +- [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 + +- [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 every MODIFIED requirement diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 05fd648c..c316c896 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -299,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 ae5873f6..98aab139 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 @@ -266,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 @@ -349,12 +365,18 @@ 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 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 behave as if `taskless init` were invoked +- **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 @@ -666,7 +688,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 +697,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 +780,48 @@ 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: Init reports what an upgrade changed and what follows + +`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, 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; +- `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` 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 + +- **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` 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 --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` diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index 3dba677f..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 diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index dd694bb9..df1e6618 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -73,6 +73,8 @@ 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. +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.) @@ -81,6 +83,17 @@ 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 +- **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/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/init.md b/packages/cli/src/agent/init.md index 7853dac1..6f26e356 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,54 +1,112 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v1) +# Topic: init (CLI v%(CLI_VERSION)s / topic v2) ## 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. +- 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 -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 install.** + ``` + %(TASKLESS_CLI)s init --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, 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. + + 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. **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 + ``` + %(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/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 eb7f520c..748df729 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -12,16 +12,18 @@ 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"; import { getCliVersion } from "../wizard/intro"; import { getOnboardTrailer } from "./onboard"; import { getRecipe } from "../prompts/recipes"; import { detectCliInvocation, + getCliPrefix, processLauncherContext, } from "../util/package-manager"; import { @@ -36,15 +38,6 @@ import { TASKLESS_DIRECTORY } from "../rules/vale/formats"; import { CLIError } from "../util/cli-error"; 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", @@ -62,44 +55,42 @@ 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( 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. @@ -109,6 +100,24 @@ export const initCommand = defineCommand({ }) ); } else { + // 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)) + .map((target) => target.dir), + migrated: result.migrated !== undefined, + previousCliVersion: result.previousCliVersion, + cliVersion: result.cliVersion, + // 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); + } if (result.reloadNotice !== undefined) { console.log(result.reloadNotice); } @@ -125,7 +134,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 @@ -176,6 +185,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."); @@ -245,6 +255,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 +281,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 @@ -263,7 +297,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. @@ -345,19 +379,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 +424,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/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/index.ts b/packages/cli/src/index.ts index 7140889c..b928280f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,7 +17,9 @@ import { shutdownTelemetry, } from "./telemetry"; 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"; @@ -94,15 +96,30 @@ 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. - if (process.stdout.isTTY === true && process.stdin.isTTY === true) { - await runCommand(initCommand, { rawArgs }); + // + // 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`. 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; + } 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/install/canonical.ts b/packages/cli/src/install/canonical.ts index 731a2699..6e97d877 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"; @@ -6,6 +6,7 @@ import { stringify } from "yaml"; import { applyCliInvocation, PROD_INVOCATION, + renderInvocationPlaceholder, withCliBuildNotice, } from "../util/invocation"; import { parseFrontmatter } from "./frontmatter"; @@ -50,46 +51,69 @@ 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, 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(renderInvocationPlaceholder(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; } /** * 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, 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(renderInvocationPlaceholder(content)) ); - return path; + return { path, changed }; } /** @@ -118,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/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..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,17 +334,52 @@ 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 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 +387,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/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/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/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/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index a838e740..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( @@ -108,7 +112,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 +121,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..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, ]); @@ -196,6 +205,77 @@ 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", + "-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`/); + + // 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:/); + }); + + 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", "-d", cwd]); + await mkdir(join(cwd, ".claude"), { recursive: true }); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-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", "-d", cwd]); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-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 }); @@ -228,7 +308,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -260,7 +339,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -275,7 +353,6 @@ describe("taskless init --no-interactive", () => { const { stdout } = await execFileAsync("node", [ binPath, "init", - "--no-interactive", "-d", cwd, ]); @@ -286,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 2ad490b4..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, @@ -95,7 +102,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())); @@ -113,6 +120,72 @@ 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, + // 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/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); + }); +}); diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index ae026e5d..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, @@ -149,14 +147,80 @@ 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", + "--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", "-d", temporaryDirectory]); + + const { stdout } = await runCli([ + "init", + "--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(); - await runCli(["init", "--no-interactive", "-d", temporaryDirectory]); + await runCli(["init", "-d", temporaryDirectory]); const { stdout } = await runCli([ "init", - "--no-interactive", "--json", "-d", temporaryDirectory, @@ -199,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}`); @@ -215,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 0cf04bb2..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"); @@ -238,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 }; @@ -319,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 @@ -339,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 941f0fdf..00f57615 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,47 @@ describe("prompt rendering", () => { expect(getPrompt("create-sg-rule")).toContain(`CLI v${__VERSION__}`); }); + 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")); + 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`).toBe(""); + expect(content, name).not.toContain("do not reuse this copy"); + } + }); + + 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([ ["create-remote-rule", "prompt"], ["improve-rule", "ruleId"], @@ -359,6 +400,22 @@ describe("header suppression", () => { 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, 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", () => { for (const topic of TOPICS) { const withoutHeader = getPrompt(topic, { header: false }); @@ -421,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", () => { diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 3e9e5a03..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,19 +80,27 @@ 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 + +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 `%(TASKLESS_CLI)s agent ` +and follow the copy it returns. + ## --anonymous 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.