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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/fix-nightly-stub-package-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@taskless/cli": patch
---

Fixed reference stubs (`.claude/`, `.agents/`, etc.) freezing a stale,
unpinned `npx @taskless/cli` invocation into their own frontmatter
`description` forever, even on a nightly install whose canonical
`.taskless/skills/taskless/SKILL.md` correctly names the pinned
`@taskless/cli-nightly@<version>` package. A stub's `description` is copied
verbatim from source and, unlike canonical content, is never rewritten for
the current build target — so any CLI invocation baked into it would go
stale on the very first release that changed. The invocation is removed from
the skill and command `description` fields entirely: the canonical file
already carries the correct, per-build invocation, and a stub always defers
to it, so there is no longer a second copy that can drift.
2 changes: 1 addition & 1 deletion commands/tskl/tskl.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: "Taskless"
description: Run any Taskless action — create/improve/delete a rule, run check, manage auth, or wire CI. Routes via `npx @taskless/cli agent <topic>` to fetch the canonical recipe and follow it.
description: Run any Taskless action — create/improve/delete a rule, run check, manage auth, or wire CI. Routes via `agent <topic>` to fetch the canonical recipe and follow it.
category: Taskless
argument-hint: <describe what you want to do>
tags:
Expand Down
152 changes: 152 additions & 0 deletions packages/cli/test/nightly/stub-description-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { buildSkillStub } from "../../src/install/canonical";
import { parseFrontmatter } from "../../src/install/frontmatter";
import {
applyInstallPlan,
buildInstallPlan,
getEmbeddedCommands,
getEmbeddedSkills,
} from "../../src/install/install";
import {
buildInvocation,
isProductionInvocation,
} from "../../src/util/invocation";

/**
* End-to-end companion to `test/stub-description-invocation.test.ts`, run
* under the `nightly` vitest project (see `vite.config.ts`) so
* `__TASKLESS_CLI__` is a real pinned `@taskless/cli-nightly@<version>`
* define, exactly as `build:nightly` produces — not something reachable under
* this repo's default prod define.
*
* taskless/cli#298: a `.claude`/`.agents` stub's frontmatter `description` is
* copied verbatim from the embedded skill/command source (see `writeSkill` /
* `writeCommand` in `src/install/install.ts`) and is NEVER passed through
* `applyCliInvocation` — only a canonical `.taskless/` file gets that rewrite.
* Before the fix, a description that named the CLI invocation would freeze a
* stale, unpinned `npx @taskless/cli` reference into every `.claude` stub
* forever, even though the canonical `.taskless/skills/taskless/SKILL.md`
* written by the SAME install correctly names this build's pinned nightly.
*
* This drives that divergence the way the issue describes it — a real
* `applyInstallPlan` under a nightly define — without installing the actual
* `@taskless/cli-nightly` package, which is blocked by a deny rule in this
* repo.
*/
describe("installing a nightly writes a stub description that names no CLI package", () => {
let cwd: string;

beforeEach(async () => {
expect(isProductionInvocation()).toBe(false);
expect(buildInvocation()).toContain("@taskless/cli-nightly@");

cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-stub-description-"));
await mkdir(join(cwd, ".taskless"), { recursive: true });
await writeFile(
join(cwd, ".taskless", "taskless.json"),
JSON.stringify({ version: 2, install: {} }),
"utf8"
);
});

afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});

const skillStubPath = () =>
join(cwd, ".claude", "skills", "taskless", "SKILL.md");
const commandStubPath = () =>
join(cwd, ".claude", "commands", "tskl", "tskl.md");
const canonicalSkillPath = () =>
join(cwd, ".taskless", "skills", "taskless", "SKILL.md");

async function install(): Promise<void> {
const skills = getEmbeddedSkills();
const commands = getEmbeddedCommands();
const plan = buildInstallPlan([".claude"], skills, commands);
await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" });
}

it("names no CLI package in the stub's frontmatter description", async () => {
await install();

const skillDescription = parseFrontmatter(
await readFile(skillStubPath(), "utf8")
).data.description as string;
const commandDescription = parseFrontmatter(
await readFile(commandStubPath(), "utf8")
).data.description as string;

// Not the released package, and not this build's own pinned nightly
// either — the fix is that description names no package at all, so there
// is nothing left to go stale on a later, differently-pinned install.
expect(skillDescription).not.toMatch(/@taskless\/cli/);
expect(commandDescription).not.toMatch(/@taskless\/cli/);
});

it("still correctly pins the canonical file's own invocation", async () => {
await install();

// Contrast case: the canonical `.taskless/` copy IS rewritten per build,
// which is what makes the stub's staleness invisible without this test —
// the canonical file looks completely correct.
const canonical = await readFile(canonicalSkillPath(), "utf8");
expect(canonical).toContain(buildInvocation());
expect(canonical).not.toContain("npx @taskless/cli agent");
});

it("a second install under the SAME pin is idempotent: nothing gets rewritten", async () => {
await install();
const first = await readFile(skillStubPath(), "utf8");

// Re-running install (same build, so same pin) must not rewrite a stub
// whose description never carried a version to begin with. The genuine
// cross-pin case — an EARLIER, differently pinned install's stub — is
// covered separately below, since a single compile-time define can't
// produce two different pins within one test run.
await install();
expect(await readFile(skillStubPath(), "utf8")).toBe(first);
});

it("a stub frozen by an EARLIER, differently pinned nightly converges on the next install", async () => {
// Simulates the actual defect in taskless/cli#298: a project installed an
// older nightly, pinned to a DIFFERENT version, back when a skill's
// `description` still carried a baked-in CLI invocation. That earlier
// install's stub is not reachable by calling `install()` twice under this
// file's single fixed `__TASKLESS_CLI__` define (a compile-time Vite
// define can't vary within one test run), so it is hand-crafted here with
// `buildSkillStub` instead — the same builder `writeSkill` itself calls,
// fed the OLD-style description a pre-fix source file would have produced.
await mkdir(dirname(skillStubPath()), { recursive: true });
const frozenByEarlierNightly = buildSkillStub({
name: "taskless",
description:
"Use for any Taskless task. Fetch recipes via " +
"`npx @taskless/cli-nightly@0.0.0-nightly.previous agent route`.",
});
await writeFile(skillStubPath(), frozenByEarlierNightly, "utf8");

await install();

const rewritten = await readFile(skillStubPath(), "utf8");
const rewrittenDescription = parseFrontmatter(rewritten).data
.description as string;

// The stub actually changed — this build's install converged it rather
// than leaving the earlier nightly's frozen copy in place.
expect(rewritten).not.toBe(frozenByEarlierNightly);
// It converged onto the CURRENT source description (invocation-free), not
// merely onto some other pin.
const currentDescription = getEmbeddedSkills().find(
(s) => s.name === "taskless"
)?.description;
expect(rewrittenDescription).toBe(currentDescription);
// And, the property the whole fix establishes: no pinned or unpinned CLI
// package reference survives, from either the old install or this one.
expect(rewrittenDescription).not.toMatch(/@taskless\/cli/);
});
});
63 changes: 63 additions & 0 deletions packages/cli/test/stub-description-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";

import { getEmbeddedCommands, getEmbeddedSkills } from "../src/install/install";

/**
* Regression coverage for taskless/cli#298.
*
* `getEmbeddedSkills`/`getEmbeddedCommands` read `name`/`description` straight
* out of a skill or command's raw frontmatter — the exact text later copied
* verbatim into a `.claude`/`.agents`/etc. reference stub's own frontmatter
* (see `writeSkill`/`writeCommand` in `src/install/install.ts`, which pass
* `skill.description`/`command.description` through unchanged as
* `StubFrontmatter.description`).
*
* That copy is NEVER routed through `applyCliInvocation`: only a canonical
* `.taskless/` file gets that rewrite (`writeCanonicalSkill`/
* `writeCanonicalCommand`). A stub's own frontmatter is deliberately kept
* byte-stable across releases so it does not churn on every version bump
* (see the `StubFrontmatter` doc comment in `src/install/canonical.ts`) — and
* `stubFrontmatterDrifted` only rewrites a stub when `name`/`description`
* actually change, so a CLI invocation baked into `description` freezes there
* across every later `init`, nightly or not.
*
* The fix is to never let a CLI invocation reach `description` in the first
* place — there is then no second, unrewritten copy for a stub to freeze. This
* test enforces that structurally: it fails if ANY embedded skill or command's
* `description` names the CLI, pinned or not, so a future author cannot
* reintroduce the class of bug by adding an invocation back into a
* `description:` field.
*
* Mutation check performed by hand: restoring the pinned literal
* `` `npx @taskless/cli agent route` `` inside `skills/taskless/SKILL.md`'s
* `description:` field made this test fail (both assertions below); reverting
* made it pass again.
*/
describe("skill/command descriptions never name the CLI package", () => {
// The one substring every form of the invocation shares: the released
// package (`npx @taskless/cli`) and a nightly's pinned form
// (`npx @taskless/cli-nightly@<version>`) both contain `@taskless/cli`. A
// bare `taskless` (the product name, used all over these descriptions in
// ordinary prose — "run taskless", "wire taskless into CI") is not this bug:
// it names no package and carries no version, so it cannot go stale across
// a channel or version change the way `@taskless/cli[-nightly]` can.
const NAMES_THE_CLI_PACKAGE = /@taskless\/cli/;

it("no embedded skill's description carries a CLI invocation", () => {
for (const skill of getEmbeddedSkills()) {
expect(
skill.description,
`skill "${skill.name}" description names the CLI package`
).not.toMatch(NAMES_THE_CLI_PACKAGE);
}
});

it("no embedded command's description carries a CLI invocation", () => {
for (const command of getEmbeddedCommands()) {
expect(
command.description,
`command "${command.filename}" description names the CLI package`
).not.toMatch(NAMES_THE_CLI_PACKAGE);
}
});
});
2 changes: 1 addition & 1 deletion skills/taskless/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ description: |
Also trigger on any request to add/write/create a lint or code rule,
including ones that name a specific tool (eslint, ruff, biome, stylelint,
ast-grep). Naming a tool ENGAGES this skill's routing flow via
`npx @taskless/cli agent route`; it does NOT suppress the skill.
`agent route`; it does NOT suppress the skill.
metadata:
author: taskless
version: 0.11.1
Expand Down
Loading