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
5 changes: 5 additions & 0 deletions .changeset/skill-version-stamp.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 37 additions & 1 deletion packages/cli/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type CommandStubFrontmatter,
type StubFrontmatter,
} from "./canonical";
import { CLI_VERSION } from "../version";
import { parseFrontmatter } from "./frontmatter";
import {
computeInstallDiff,
Expand Down Expand Up @@ -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;
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/test/nightly/skill-version-stamp.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
});
44 changes: 44 additions & 0 deletions packages/cli/test/stamp-skill-version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading