From c9104c464a9a59df9e1c73329621cbfd5853950e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 19:40:09 -0700 Subject: [PATCH 1/2] fix(cli): stamp the installed skill's metadata.version with the build that wrote it A nightly rewrote the canonical skill body to pin itself but left metadata.version at the committed package version, and checkStaleness compared that stamp against the same embedded value, so info reported the skill current either way. getEmbeddedSkills now stamps each skill with the running build's version, so an install writes it and the staleness check reads it. Fixes #447 --- .changeset/skill-version-stamp.md | 5 + packages/cli/src/install/install.ts | 38 +++++++- .../test/nightly/skill-version-stamp.test.ts | 93 +++++++++++++++++++ packages/cli/test/stamp-skill-version.test.ts | 38 ++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .changeset/skill-version-stamp.md create mode 100644 packages/cli/test/nightly/skill-version-stamp.test.ts create mode 100644 packages/cli/test/stamp-skill-version.test.ts diff --git a/.changeset/skill-version-stamp.md b/.changeset/skill-version-stamp.md new file mode 100644 index 00000000..e59d42bc --- /dev/null +++ b/.changeset/skill-version-stamp.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +The installed Taskless skill's `metadata.version` now names the build that wrote it. A nightly or `self` build rewrote the skill body to pin itself but left the frontmatter at the last release, so `info` compared that stale stamp against itself and reported the skill current either way. diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index c2714dad..ce65addd 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -13,6 +13,7 @@ import { type CommandStubFrontmatter, type StubFrontmatter, } from "./canonical"; +import { CLI_VERSION } from "../version"; import { parseFrontmatter } from "./frontmatter"; import { computeInstallDiff, @@ -260,8 +261,43 @@ export async function detectSelectedDirectories( // --- Embedded Skills --- +/** The `version:` line of a frontmatter `metadata:` block, up to its value. */ +const METADATA_VERSION = + /^(metadata:\n(?:[ \t]+.*\n)*?[ \t]+version:[ \t]*).*$/m; + +/** + * Stamp `metadata.version` in a skill's frontmatter with `version`. + * + * The source carries the committed package version, which the build asserts + * (`assertSkillVersions` in `vite.config.ts`). That is the right stamp for a + * prod build and the wrong one for a nightly or `self` build, whose + * `__VERSION__` is not the package version: taskless/cli#447 shipped a nightly + * that rewrote the skill body to pin itself while the frontmatter kept naming + * the last release, and `info` compared that stale stamp against the same + * stale stamp and reported the skill current. Only the frontmatter is touched, + * so a body line that happens to read `version:` is left alone. + */ +export function stampSkillVersion(content: string, version: string): string { + const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(content)?.[0]; + if (frontmatter === undefined) return content; + return ( + frontmatter.replace(METADATA_VERSION, `$1${version}`) + + content.slice(frontmatter.length) + ); +} + +/** + * The skills embedded in this build, each stamped with the version of the + * build itself (see {@link stampSkillVersion}). Both sides of + * {@link checkStaleness} derive from this: what an install writes, and what + * the running build considers current. + */ export function getEmbeddedSkills(): EmbeddedSkill[] { - return Object.entries(skillFiles).map(([path, content]) => { + return Object.entries(skillFiles).map(([path, source]) => { + const content = + CLI_VERSION === "unknown" + ? source + : stampSkillVersion(source, CLI_VERSION); const parsed = parseFrontmatter(content); const data = parsed.data as { name?: string; diff --git a/packages/cli/test/nightly/skill-version-stamp.test.ts b/packages/cli/test/nightly/skill-version-stamp.test.ts new file mode 100644 index 00000000..5ba5a629 --- /dev/null +++ b/packages/cli/test/nightly/skill-version-stamp.test.ts @@ -0,0 +1,93 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { parseFrontmatter } from "../../src/install/frontmatter"; +import { + applyInstallPlan, + buildInstallPlan, + checkStaleness, + getEmbeddedCommands, + getEmbeddedSkills, +} from "../../src/install/install"; +import { CLI_VERSION } from "../../src/version"; + +/** + * taskless/cli#447, under the `nightly` vitest project (see `vite.config.ts`), + * where `__VERSION__` is a nightly version rather than the committed package + * version the skill source carries. A nightly `init` rewrote the canonical + * skill's body to pin itself but left `metadata.version` at the last release, + * and `info` compared that stale stamp against the same stale stamp. + */ +describe("installing a nightly stamps the skill with the nightly's version", () => { + let cwd: string; + + beforeEach(async () => { + expect(CLI_VERSION).toBe("0.0.0-nightly.test"); + + cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-skill-version-")); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await mkdir(join(cwd, ".claude"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: {} }), + "utf8" + ); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function install(): Promise { + const plan = buildInstallPlan( + [".claude"], + getEmbeddedSkills(), + getEmbeddedCommands() + ); + await applyInstallPlan(cwd, plan, { cliVersion: CLI_VERSION }); + } + + it("writes metadata.version as the running build", async () => { + await install(); + + const canonical = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + const metadata = parseFrontmatter(canonical).data.metadata as Record< + string, + string + >; + expect(metadata.version).toBe(CLI_VERSION); + }); + + it("reports both sides of the staleness check as the running build", async () => { + await install(); + + const [tool] = await checkStaleness(cwd); + expect(tool?.skills.length).toBeGreaterThan(0); + for (const skill of tool!.skills) { + expect(skill.installedVersion).toBe(CLI_VERSION); + expect(skill.currentVersion).toBe(CLI_VERSION); + expect(skill.current).toBe(true); + } + }); + + it("reports a skill a release wrote as stale", async () => { + await install(); + const path = join(cwd, ".taskless", "skills", "taskless", "SKILL.md"); + const canonical = await readFile(path, "utf8"); + await writeFile( + path, + canonical.replace(`version: ${CLI_VERSION}`, "version: 0.11.2"), + "utf8" + ); + + const [tool] = await checkStaleness(cwd); + const skill = tool!.skills.find((s) => s.name === "taskless"); + expect(skill?.installedVersion).toBe("0.11.2"); + expect(skill?.current).toBe(false); + }); +}); diff --git a/packages/cli/test/stamp-skill-version.test.ts b/packages/cli/test/stamp-skill-version.test.ts new file mode 100644 index 00000000..2576f4b5 --- /dev/null +++ b/packages/cli/test/stamp-skill-version.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { stampSkillVersion } from "../src/install/install"; + +const SKILL = `--- +name: example +description: | + Mentions version: 1.0.0 in prose. +metadata: + author: taskless + version: 0.11.2 + commandName: tskl +--- + +metadata: + version: 0.11.2 +`; + +describe("stampSkillVersion", () => { + it("rewrites metadata.version in the frontmatter only", () => { + expect(stampSkillVersion(SKILL, "0.12.0-self")).toBe( + SKILL.replace( + " version: 0.11.2\n commandName", + " version: 0.12.0-self\n commandName" + ) + ); + }); + + it("leaves content without frontmatter alone", () => { + const body = "metadata:\n version: 0.11.2\n"; + expect(stampSkillVersion(body, "0.12.0")).toBe(body); + }); + + it("leaves frontmatter without a metadata.version alone", () => { + const content = "---\nname: example\n---\n\nbody\n"; + expect(stampSkillVersion(content, "0.12.0")).toBe(content); + }); +}); From 9603cb9304b105289784f479acc3ae2e3241e46a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 19:54:15 -0700 Subject: [PATCH 2/2] test(cli): assert a metadata block without a version is left alone --- packages/cli/test/stamp-skill-version.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/stamp-skill-version.test.ts b/packages/cli/test/stamp-skill-version.test.ts index 2576f4b5..b06e274b 100644 --- a/packages/cli/test/stamp-skill-version.test.ts +++ b/packages/cli/test/stamp-skill-version.test.ts @@ -31,8 +31,14 @@ describe("stampSkillVersion", () => { expect(stampSkillVersion(body, "0.12.0")).toBe(body); }); - it("leaves frontmatter without a metadata.version alone", () => { + it("leaves frontmatter without a metadata block alone", () => { const content = "---\nname: example\n---\n\nbody\n"; expect(stampSkillVersion(content, "0.12.0")).toBe(content); }); + + it("leaves a metadata block without a version alone", () => { + const content = + "---\nname: example\nmetadata:\n author: taskless\n---\n\nmetadata:\n version: 0.11.2\n"; + expect(stampSkillVersion(content, "0.12.0")).toBe(content); + }); });