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..b06e274b --- /dev/null +++ b/packages/cli/test/stamp-skill-version.test.ts @@ -0,0 +1,44 @@ +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 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); + }); +});