Skip to content

Commit 97cd2f7

Browse files
committed
fix(cli): stop letting a stub's own description freeze a stale CLI reference
A `.claude`/`.agents` reference stub's frontmatter `description` is copied verbatim from the embedded skill/command source, and unlike the canonical `.taskless/` copy, is never passed through the build-target invocation rewrite. A description that named the CLI invocation therefore froze a stale, unpinned `npx @taskless/cli` reference into every stub forever, even on a nightly install whose canonical file correctly names the pinned `@taskless/cli-nightly@<version>` package. Remove the invocation from the skill and command `description` fields entirely, so there is no second, unrewritten copy left to go stale. The canonical file already carries the correct per-build invocation, and a stub always defers to it. Adds a source-level guard against reintroducing a CLI package reference into any description, plus an end-to-end nightly-build test that installs and reads back the actual stub.
1 parent 2ffb3d1 commit 97cd2f7

5 files changed

Lines changed: 190 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@taskless/cli": patch
3+
---
4+
5+
Fixed reference stubs (`.claude/`, `.agents/`, etc.) freezing a stale,
6+
unpinned `npx @taskless/cli` invocation into their own frontmatter
7+
`description` forever, even on a nightly install whose canonical
8+
`.taskless/skills/taskless/SKILL.md` correctly names the pinned
9+
`@taskless/cli-nightly@<version>` package. A stub's `description` is copied
10+
verbatim from source and, unlike canonical content, is never rewritten for
11+
the current build target — so any CLI invocation baked into it would go
12+
stale on the very first release that changed. The invocation is removed from
13+
the skill and command `description` fields entirely: the canonical file
14+
already carries the correct, per-build invocation, and a stub always defers
15+
to it, so there is no longer a second copy that can drift.

‎commands/tskl/tskl.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: "Taskless"
3-
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.
3+
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.
44
category: Taskless
55
argument-hint: <describe what you want to do>
66
tags:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
6+
import { parseFrontmatter } from "../../src/install/frontmatter";
7+
import {
8+
applyInstallPlan,
9+
buildInstallPlan,
10+
getEmbeddedCommands,
11+
getEmbeddedSkills,
12+
} from "../../src/install/install";
13+
import {
14+
buildInvocation,
15+
isProductionInvocation,
16+
} from "../../src/util/invocation";
17+
18+
/**
19+
* End-to-end companion to `test/stub-description-invocation.test.ts`, run
20+
* under the `nightly` vitest project (see `vite.config.ts`) so
21+
* `__TASKLESS_CLI__` is a real pinned `@taskless/cli-nightly@<version>`
22+
* define, exactly as `build:nightly` produces — not something reachable under
23+
* this repo's default prod define.
24+
*
25+
* taskless/cli#298: a `.claude`/`.agents` stub's frontmatter `description` is
26+
* copied verbatim from the embedded skill/command source (see `writeSkill` /
27+
* `writeCommand` in `src/install/install.ts`) and is NEVER passed through
28+
* `applyCliInvocation` — only a canonical `.taskless/` file gets that rewrite.
29+
* Before the fix, a description that named the CLI invocation would freeze a
30+
* stale, unpinned `npx @taskless/cli` reference into every `.claude` stub
31+
* forever, even though the canonical `.taskless/skills/taskless/SKILL.md`
32+
* written by the SAME install correctly names this build's pinned nightly.
33+
*
34+
* This drives that divergence the way the issue describes it — a real
35+
* `applyInstallPlan` under a nightly define — without installing the actual
36+
* `@taskless/cli-nightly` package, which is blocked by a deny rule in this
37+
* repo.
38+
*/
39+
describe("installing a nightly writes a stub description that names no CLI package", () => {
40+
let cwd: string;
41+
42+
beforeEach(async () => {
43+
expect(isProductionInvocation()).toBe(false);
44+
expect(buildInvocation()).toContain("@taskless/cli-nightly@");
45+
46+
cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-stub-description-"));
47+
await mkdir(join(cwd, ".taskless"), { recursive: true });
48+
await writeFile(
49+
join(cwd, ".taskless", "taskless.json"),
50+
JSON.stringify({ version: 2, install: {} }),
51+
"utf8"
52+
);
53+
});
54+
55+
afterEach(async () => {
56+
await rm(cwd, { recursive: true, force: true });
57+
});
58+
59+
const skillStubPath = () =>
60+
join(cwd, ".claude", "skills", "taskless", "SKILL.md");
61+
const commandStubPath = () =>
62+
join(cwd, ".claude", "commands", "tskl", "tskl.md");
63+
const canonicalSkillPath = () =>
64+
join(cwd, ".taskless", "skills", "taskless", "SKILL.md");
65+
66+
async function install(): Promise<void> {
67+
const skills = getEmbeddedSkills();
68+
const commands = getEmbeddedCommands();
69+
const plan = buildInstallPlan([".claude"], skills, commands);
70+
await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" });
71+
}
72+
73+
it("names no CLI package in the stub's frontmatter description", async () => {
74+
await install();
75+
76+
const skillDescription = parseFrontmatter(
77+
await readFile(skillStubPath(), "utf8")
78+
).data.description as string;
79+
const commandDescription = parseFrontmatter(
80+
await readFile(commandStubPath(), "utf8")
81+
).data.description as string;
82+
83+
// Not the released package, and not this build's own pinned nightly
84+
// either — the fix is that description names no package at all, so there
85+
// is nothing left to go stale on a later, differently-pinned install.
86+
expect(skillDescription).not.toMatch(/@taskless\/cli/);
87+
expect(commandDescription).not.toMatch(/@taskless\/cli/);
88+
});
89+
90+
it("still correctly pins the canonical file's own invocation", async () => {
91+
await install();
92+
93+
// Contrast case: the canonical `.taskless/` copy IS rewritten per build,
94+
// which is what makes the stub's staleness invisible without this test —
95+
// the canonical file looks completely correct.
96+
const canonical = await readFile(canonicalSkillPath(), "utf8");
97+
expect(canonical).toContain(buildInvocation());
98+
expect(canonical).not.toContain("npx @taskless/cli agent");
99+
});
100+
101+
it("a second install pinned to a different nightly leaves the stub unchanged", async () => {
102+
await install();
103+
const first = await readFile(skillStubPath(), "utf8");
104+
105+
// Re-running install (same build, so same pin) must not rewrite a stub
106+
// whose description never carried a version to begin with.
107+
await install();
108+
expect(await readFile(skillStubPath(), "utf8")).toBe(first);
109+
});
110+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { getEmbeddedCommands, getEmbeddedSkills } from "../src/install/install";
4+
5+
/**
6+
* Regression coverage for taskless/cli#298.
7+
*
8+
* `getEmbeddedSkills`/`getEmbeddedCommands` read `name`/`description` straight
9+
* out of a skill or command's raw frontmatter — the exact text later copied
10+
* verbatim into a `.claude`/`.agents`/etc. reference stub's own frontmatter
11+
* (see `writeSkill`/`writeCommand` in `src/install/install.ts`, which pass
12+
* `skill.description`/`command.description` through unchanged as
13+
* `StubFrontmatter.description`).
14+
*
15+
* That copy is NEVER routed through `applyCliInvocation`: only a canonical
16+
* `.taskless/` file gets that rewrite (`writeCanonicalSkill`/
17+
* `writeCanonicalCommand`). A stub's own frontmatter is deliberately kept
18+
* byte-stable across releases so it does not churn on every version bump
19+
* (see the `StubFrontmatter` doc comment in `src/install/canonical.ts`) — and
20+
* `stubFrontmatterDrifted` only rewrites a stub when `name`/`description`
21+
* actually change, so a CLI invocation baked into `description` freezes there
22+
* across every later `init`, nightly or not.
23+
*
24+
* The fix is to never let a CLI invocation reach `description` in the first
25+
* place — there is then no second, unrewritten copy for a stub to freeze. This
26+
* test enforces that structurally: it fails if ANY embedded skill or command's
27+
* `description` names the CLI, pinned or not, so a future author cannot
28+
* reintroduce the class of bug by adding an invocation back into a
29+
* `description:` field.
30+
*
31+
* Mutation check performed by hand: restoring the pinned literal
32+
* `` `npx @taskless/cli agent route` `` inside `skills/taskless/SKILL.md`'s
33+
* `description:` field made this test fail (both assertions below); reverting
34+
* made it pass again.
35+
*/
36+
describe("skill/command descriptions never name the CLI package", () => {
37+
// The one substring every form of the invocation shares: the released
38+
// package (`npx @taskless/cli`) and a nightly's pinned form
39+
// (`npx @taskless/cli-nightly@<version>`) both contain `@taskless/cli`. A
40+
// bare `taskless` (the product name, used all over these descriptions in
41+
// ordinary prose — "run taskless", "wire taskless into CI") is not this bug:
42+
// it names no package and carries no version, so it cannot go stale across
43+
// a channel or version change the way `@taskless/cli[-nightly]` can.
44+
const NAMES_THE_CLI_PACKAGE = /@taskless\/cli/;
45+
46+
it("no embedded skill's description carries a CLI invocation", () => {
47+
for (const skill of getEmbeddedSkills()) {
48+
expect(
49+
skill.description,
50+
`skill "${skill.name}" description names the CLI package`
51+
).not.toMatch(NAMES_THE_CLI_PACKAGE);
52+
}
53+
});
54+
55+
it("no embedded command's description carries a CLI invocation", () => {
56+
for (const command of getEmbeddedCommands()) {
57+
expect(
58+
command.description,
59+
`command "${command.filename}" description names the CLI package`
60+
).not.toMatch(NAMES_THE_CLI_PACKAGE);
61+
}
62+
});
63+
});

‎skills/taskless/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ description: |
1717
Also trigger on any request to add/write/create a lint or code rule,
1818
including ones that name a specific tool (eslint, ruff, biome, stylelint,
1919
ast-grep). Naming a tool ENGAGES this skill's routing flow via
20-
`npx @taskless/cli agent route`; it does NOT suppress the skill.
20+
`agent route`; it does NOT suppress the skill.
2121
metadata:
2222
author: taskless
2323
version: 0.11.1

0 commit comments

Comments
 (0)