diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8df62..45d7b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] ### Added +- `skillforge update `: bump the `version:` field of a SKILL.md in one shot — `--bump ` or `--new-version ` (mutually exclusive, exactly one required). Pre-release tags (`-beta`, `-rc.1`, …) are dropped on any bump, matching `npm version`. A missing `version:` field is treated as the schema default of `0.0.1`. Validates the proposed frontmatter against the schema before touching disk and uses a line-surgical write so the body bytes and other YAML formatting are preserved byte-for-byte. `--dry-run` reports the new version without writing. - `skillforge lint `: warnings-first style/quality linter for `SKILL.md` files. A stricter peer of `validate` that surfaces nine smells `validate` deliberately ignores — short or noun-phrase `description`, descriptions missing trigger language, empty `tags`, stale `version: 0.0.1` files (older than 7 days), missing `## When to use` / `## Examples` headings, `TODO` markers (error), `you should` / `always` second-person phrasing, and trailing whitespace. Exit 0 if only warnings, 1 on errors, 2 with `--strict`. `--json` emits machine-readable issues. Each rule is a tiny pure function so adding rules is a one-liner. ## [0.0.2] — 2026-05-23 diff --git a/README.md b/README.md index 9620fd3..13bf230 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Scaffold, validate, and lint `SKILL.md` files for the agent ecosystem. --- -> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, and `install` work today. Registry, publish, and eval flows land in v0.1. +> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, and `update` work today. Registry, publish, and eval flows land in v0.1. ## Install @@ -92,6 +92,20 @@ skillforge install https://example.com/code-review.skill Refuses plaintext `http://` (skills execute on your machine), refuses zip-slip entries, refuses symlinks in archives, caps downloads at 64 MB, and validates the bundle's `SKILL.md` before writing a single file to disk. Pass `--force` to clear an existing install directory before extracting; pass `--dry-run` to validate and report what would happen without touching the filesystem. +### `skillforge update ` + +Bump the `version:` field of a SKILL.md without hand-editing the frontmatter. Accepts either a file path or a directory containing a `SKILL.md`: + +```bash +skillforge update ./code-review --bump patch +# ✓ ./code-review/SKILL.md: 0.1.0 → 0.1.1 + +skillforge update ./code-review/SKILL.md --new-version 1.0.0 +# ✓ ./code-review/SKILL.md: 0.1.1 → 1.0.0 +``` + +Pass exactly one of `--bump ` or `--new-version `. Pre-release tags (`-beta`, `-rc.1`, …) are dropped on any bump, matching `npm version`. A missing `version:` field is treated as `0.0.1` (the schema default) so a `patch` on a freshly-scaffolded skill produces `0.0.2`. The proposed frontmatter is validated against the schema before anything hits disk, and the write is line-surgical — body bytes, field order, and other YAML formatting are preserved byte-for-byte. Pass `--dry-run` to print the would-be new version without writing. + ## Schema ```yaml diff --git a/src/cli.ts b/src/cli.ts index d67a2ec..c3b429a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ * lint surface style/quality warnings on a SKILL.md * pack bundle a skill directory into a .skill archive * install download a remote .skill into ~/.claude/skills/ + * update bump the version field of a SKILL.md */ import { cac } from "cac"; import kleur from "kleur"; @@ -14,6 +15,7 @@ import { initSkill } from "./init.js"; import { installSkill } from "./install.js"; import { computeExitCode, lintSkill } from "./lint.js"; import { packSkill } from "./pack.js"; +import { type BumpKind, updateSkillVersion } from "./update.js"; import { validateSkill } from "./validate.js"; const VERSION = "0.0.2"; @@ -145,6 +147,34 @@ cli } }); +cli + .command("update ", "Bump the version field of a SKILL.md") + .option("--bump ", "Bump direction: patch, minor, or major") + .option("--new-version ", "Set the version to an explicit semver string") + .option("--dry-run", "Report the would-be new version without writing") + .action(async (path: string, opts) => { + try { + if (opts.bump !== undefined && !["patch", "minor", "major"].includes(opts.bump)) { + throw new Error(`--bump must be one of patch, minor, major (got "${opts.bump}")`); + } + const result = await updateSkillVersion({ + path, + bump: opts.bump as BumpKind | undefined, + newVersion: opts.newVersion, + dryRun: !!opts.dryRun, + }); + const prefix = result.dryRun ? kleur.yellow("dry-run") : kleur.green("✓"); + const suffix = result.dryRun ? " (nothing written)" : ""; + process.stdout.write( + `${prefix} ${result.path}: ${result.oldVersion} → ${result.newVersion}${suffix}\n`, + ); + process.exit(0); + } catch (err) { + process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`); + process.exit(1); + } + }); + cli.help(); cli.version(VERSION); cli.parse(); diff --git a/src/index.ts b/src/index.ts index a37e2a4..631ef48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,4 +9,11 @@ export { type Severity, } from "./lint.js"; export { SkillFrontmatterSchema, type SkillFrontmatter } from "./schema.js"; +export { + type BumpKind, + bumpVersion, + type UpdateOptions, + type UpdateResult, + updateSkillVersion, +} from "./update.js"; export { validateSkill, type ValidateResult } from "./validate.js"; diff --git a/src/update.test.ts b/src/update.test.ts new file mode 100644 index 0000000..7aed50e --- /dev/null +++ b/src/update.test.ts @@ -0,0 +1,320 @@ +import { realpathSync } from "node:fs"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import matter from "gray-matter"; +import { beforeEach, describe, expect, it } from "vitest"; +import { bumpVersion, updateSkillVersion } from "./update.js"; + +let workDir: string; + +beforeEach(async () => { + // realpath because macOS `tmpdir()` returns `/tmp` while resolved paths + // come back as `/private/tmp`. Same trick as pack.test.ts. + workDir = realpathSync(await mkdtemp(join(tmpdir(), "skillforge-update-"))); +}); + +interface SkillOverrides { + name?: string; + description?: string; + version?: string; + tags?: string; + body?: string; + /** Lets a test write totally custom frontmatter (e.g. unusual field order). */ + customFrontmatter?: string; +} + +const DEFAULT_BODY = `# my-skill + +## What this skill does + +Real prose. Real prose. Real prose. Real prose. + +## When to use + +When the agent wants to demonstrate something. + +## Examples + +\`\`\` +example +\`\`\` +`; + +async function writeSkill(dir: string, o: SkillOverrides = {}): Promise { + const file = join(dir, "SKILL.md"); + if (o.customFrontmatter) { + await writeFile(file, `---\n${o.customFrontmatter}\n---\n\n${o.body ?? DEFAULT_BODY}`); + return file; + } + const fm = [ + `name: ${o.name ?? "my-skill"}`, + `description: ${o.description ?? "Use this when the user asks for the demo skill described in this file."}`, + `version: ${o.version ?? "1.2.3"}`, + `tags: ${o.tags ?? "[]"}`, + ].join("\n"); + await writeFile(file, `---\n${fm}\n---\n\n${o.body ?? DEFAULT_BODY}`); + return file; +} + +describe("bumpVersion", () => { + it("patch increments the patch component", () => { + expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4"); + }); + + it("minor increments minor and zeroes patch", () => { + expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0"); + }); + + it("major increments major and zeroes minor/patch", () => { + expect(bumpVersion("1.2.3", "major")).toBe("2.0.0"); + }); + + it("drops prerelease tag on patch bump (npm-style)", () => { + expect(bumpVersion("1.2.3-beta", "patch")).toBe("1.2.4"); + }); + + it("drops prerelease tag on minor bump", () => { + expect(bumpVersion("1.2.3-beta.1", "minor")).toBe("1.3.0"); + }); + + it("drops prerelease tag on major bump", () => { + expect(bumpVersion("1.2.3-rc.1", "major")).toBe("2.0.0"); + }); + + it("throws on unparseable input", () => { + expect(() => bumpVersion("not-semver", "patch")).toThrow(/cannot parse/); + }); +}); + +describe("updateSkillVersion — bump from 1.2.3", () => { + it("patch → 1.2.4", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: f, bump: "patch" }); + expect(r.oldVersion).toBe("1.2.3"); + expect(r.newVersion).toBe("1.2.4"); + expect(r.dryRun).toBe(false); + const written = await readFile(f, "utf8"); + expect(matter(written).data.version).toBe("1.2.4"); + }); + + it("minor → 1.3.0", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: f, bump: "minor" }); + expect(r.newVersion).toBe("1.3.0"); + const written = await readFile(f, "utf8"); + expect(matter(written).data.version).toBe("1.3.0"); + }); + + it("major → 2.0.0", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: f, bump: "major" }); + expect(r.newVersion).toBe("2.0.0"); + const written = await readFile(f, "utf8"); + expect(matter(written).data.version).toBe("2.0.0"); + }); +}); + +describe("updateSkillVersion — schema default baseline", () => { + it("bumps from 0.0.1 when version field is absent", async () => { + // No `version:` line in the frontmatter at all. + const customFm = [ + "name: schema-default", + "description: Use this when the user wants to demonstrate the schema-default baseline behaviour.", + "tags: []", + ].join("\n"); + const f = await writeSkill(workDir, { customFrontmatter: customFm }); + const r = await updateSkillVersion({ path: f, bump: "patch" }); + expect(r.oldVersion).toBe("0.0.1"); + expect(r.newVersion).toBe("0.0.2"); + const written = await readFile(f, "utf8"); + expect(matter(written).data.version).toBe("0.0.2"); + }); +}); + +describe("updateSkillVersion — prerelease handling", () => { + it("strips -beta on patch bump", async () => { + const f = await writeSkill(workDir, { version: "1.2.3-beta" }); + const r = await updateSkillVersion({ path: f, bump: "patch" }); + expect(r.newVersion).toBe("1.2.4"); + const written = await readFile(f, "utf8"); + expect(written).toContain("version: 1.2.4"); + expect(written).not.toContain("beta"); + }); + + it("strips -rc.1 on major bump", async () => { + const f = await writeSkill(workDir, { version: "1.2.3-rc.1" }); + const r = await updateSkillVersion({ path: f, bump: "major" }); + expect(r.newVersion).toBe("2.0.0"); + }); +}); + +describe("updateSkillVersion — dry-run", () => { + it("reports the new version but writes nothing", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const before = await readFile(f, "utf8"); + const r = await updateSkillVersion({ path: f, bump: "minor", dryRun: true }); + expect(r.newVersion).toBe("1.3.0"); + expect(r.dryRun).toBe(true); + const after = await readFile(f, "utf8"); + expect(after).toBe(before); + }); +}); + +describe("updateSkillVersion — --new-version path", () => { + it("accepts a valid semver string", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: f, newVersion: "9.9.9" }); + expect(r.newVersion).toBe("9.9.9"); + const written = await readFile(f, "utf8"); + expect(matter(written).data.version).toBe("9.9.9"); + }); + + it("accepts a prerelease semver", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: f, newVersion: "2.0.0-beta.1" }); + expect(r.newVersion).toBe("2.0.0-beta.1"); + }); + + it("rejects garbage semver", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + await expect(updateSkillVersion({ path: f, newVersion: "not-a-version" })).rejects.toThrow( + /not valid semver/, + ); + }); + + it("rejects partial semver like '1.2'", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + await expect(updateSkillVersion({ path: f, newVersion: "1.2" })).rejects.toThrow( + /not valid semver/, + ); + }); +}); + +describe("updateSkillVersion — body preservation", () => { + it("leaves the body bytes byte-for-byte identical (prose + code blocks + lists)", async () => { + const richBody = `# my-skill + +## What this skill does + +A paragraph with *italic*, **bold**, and \`inline code\`. + +## When to use + +- bullet one +- bullet two + - nested bullet +- bullet three + +## Instructions + +1. ordered step +2. another step + +## Examples + +\`\`\`python +def hello(): + # comment with trailing spaces in the middle + print("hi") +\`\`\` + +> A blockquote with a [link](https://example.com). + +Trailing line. +`; + const f = await writeSkill(workDir, { version: "1.2.3", body: richBody }); + const before = await readFile(f, "utf8"); + const beforeBody = before.slice(before.indexOf("\n---\n") + 5); + + await updateSkillVersion({ path: f, bump: "patch" }); + + const after = await readFile(f, "utf8"); + const afterBody = after.slice(after.indexOf("\n---\n") + 5); + expect(afterBody).toBe(beforeBody); + }); +}); + +describe("updateSkillVersion — frontmatter field-order preservation", () => { + it("preserves a non-trivial field order (name, version, description, tags)", async () => { + const customFm = [ + "name: order-test", + "version: 1.2.3", + "description: Use this when verifying that the version bump does not reorder frontmatter fields.", + "tags: [a, b]", + "author: '@adityachilka1'", + ].join("\n"); + const f = await writeSkill(workDir, { customFrontmatter: customFm }); + await updateSkillVersion({ path: f, bump: "patch" }); + const after = await readFile(f, "utf8"); + // Extract just the frontmatter block (between the two `---` fences). + const lines = after.split("\n"); + const closeIdx = lines.indexOf("---", 1); + const fmLines = lines.slice(1, closeIdx); + const keysInOrder = fmLines.map((l) => l.split(":")[0].trim()).filter((k) => k.length > 0); + expect(keysInOrder).toEqual(["name", "version", "description", "tags", "author"]); + // And the version actually changed. + expect(fmLines.find((l) => l.startsWith("version:"))).toBe("version: 1.2.4"); + }); + + it("preserves indentation and colon spacing on the version line", async () => { + const customFm = [ + "name: spacing-test", + `description: ${"x".repeat(40)}`, + "version: 1.2.3", + ].join("\n"); + const f = await writeSkill(workDir, { customFrontmatter: customFm }); + await updateSkillVersion({ path: f, bump: "patch" }); + const after = await readFile(f, "utf8"); + // Original had four spaces after the colon; we preserve that whitespace. + expect(after).toContain("version: 1.2.4"); + }); +}); + +describe("updateSkillVersion — error paths", () => { + it("throws when the file does not exist", async () => { + await expect( + updateSkillVersion({ path: join(workDir, "missing.md"), bump: "patch" }), + ).rejects.toThrow(/does not exist/); + }); + + it("throws when given a directory without a SKILL.md", async () => { + await expect(updateSkillVersion({ path: workDir, bump: "patch" })).rejects.toThrow( + /does not contain a SKILL\.md/, + ); + }); + + it("resolves SKILL.md from a directory path", async () => { + await writeSkill(workDir, { version: "1.2.3" }); + const r = await updateSkillVersion({ path: workDir, bump: "patch" }); + expect(r.newVersion).toBe("1.2.4"); + expect(r.path).toBe(join(workDir, "SKILL.md")); + }); + + it("throws when both bump and newVersion are supplied", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + await expect( + updateSkillVersion({ path: f, bump: "patch", newVersion: "9.9.9" }), + ).rejects.toThrow(/exactly one of/); + }); + + it("throws when neither bump nor newVersion is supplied", async () => { + const f = await writeSkill(workDir, { version: "1.2.3" }); + await expect(updateSkillVersion({ path: f })).rejects.toThrow(/pass one of/); + }); + + it("refuses to write when the resulting frontmatter fails schema validation", async () => { + // description is too short — schema demands >= 20 chars. The bump + // itself is fine, but the file would still be invalid after writing, + // so we refuse. + const customFm = ["name: too-short", "description: tiny", "version: 1.2.3"].join("\n"); + const f = await writeSkill(workDir, { customFrontmatter: customFm }); + const before = await readFile(f, "utf8"); + await expect(updateSkillVersion({ path: f, bump: "patch" })).rejects.toThrow( + /resulting frontmatter is invalid/, + ); + // And it didn't half-write the file. + const after = await readFile(f, "utf8"); + expect(after).toBe(before); + }); +}); diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..cd1a983 --- /dev/null +++ b/src/update.ts @@ -0,0 +1,239 @@ +/** + * `skillforge update --bump|--new-version` — bump a SKILL.md version. + * + * Fifth piece of the authoring workflow after `init`, `validate`, `pack`, + * `install`, `lint`. Hand-editing the `version:` line of a SKILL.md is + * mechanical and error-prone (typos break the semver regex); this command + * does it in one shot, validates the result against the same schema + * `validate` uses, and refuses to write a file that wouldn't pass. + * + * Body-preservation strategy: we deliberately do NOT round-trip through + * `gray-matter`'s `.stringify()`. That helper re-emits YAML through `js-yaml` + * which has its own opinions about quoting, key spacing, and `|`/`>` block + * scalars — fine for new files, lossy for files a human just wrote. Instead + * we locate the frontmatter fence in the original raw text and do a + * line-surgical edit of just the `version:` line (or insert one near the + * top of the block if absent). The body bytes are never touched. The rest + * of the frontmatter — field order, comments, exotic YAML — is preserved + * verbatim. + * + * Pre-release semantics: per the semver spec, a `major`/`minor`/`patch` + * bump from a pre-release version (e.g. `1.2.3-beta`) drops the pre-release + * tag. `1.2.3-beta` → patch → `1.2.4`, NOT `1.2.4-beta`. The same applies + * to `--new-version`: it just sets whatever you ask for, including a + * pre-release if that's what you pass. + */ +import { existsSync } from "node:fs"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import matter from "gray-matter"; +import { SkillFrontmatterSchema } from "./schema.js"; + +export type BumpKind = "patch" | "minor" | "major"; + +export interface UpdateOptions { + /** Path to a SKILL.md file or a directory containing one. */ + path: string; + /** Bump direction. Mutually exclusive with `newVersion`. */ + bump?: BumpKind; + /** Explicit new version. Mutually exclusive with `bump`. */ + newVersion?: string; + /** Report the would-be new version without writing. */ + dryRun?: boolean; +} + +export interface UpdateResult { + /** Resolved path of the SKILL.md file we touched (or would have touched). */ + path: string; + /** Version we read off the file before bumping; `0.0.1` (the schema default) if absent. */ + oldVersion: string; + /** Version after the bump (or the value of `--new-version`). */ + newVersion: string; + /** Mirrors `opts.dryRun`. */ + dryRun: boolean; +} + +const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.+]+)?$/; +const SCHEMA_DEFAULT_VERSION = "0.0.1"; + +/** + * Parse a semver string into `[major, minor, patch, prerelease|null]`. + * Throws if the string doesn't match the schema's regex. + */ +function parseSemver(v: string): [number, number, number, string | null] { + const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.+]+))?$/); + if (!m) { + throw new Error(`update: cannot parse "${v}" as semver`); + } + return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? null]; +} + +/** + * Bump a semver string. Per the semver spec, any of patch/minor/major + * applied to a pre-release version drops the pre-release tag. + */ +export function bumpVersion(current: string, kind: BumpKind): string { + const [major, minor, patch, prerelease] = parseSemver(current); + switch (kind) { + case "major": + return `${major + 1}.0.0`; + case "minor": + return `${major}.${minor + 1}.0`; + case "patch": + // If we're on a prerelease, the spec says patch bump "drops the + // prerelease" but the resulting release IS the same patch number. + // i.e. 1.2.3-beta + patch → 1.2.3 (the released form), not 1.2.4. + // BUT — most tooling (npm, cargo) does 1.2.4 here, and the task + // explicitly says "drop the pre-release tag on any bump", which is + // unambiguous: drop the tag, take the bumped number. Match that. + if (prerelease !== null) { + // Drop prerelease AND bump patch — matches `npm version patch` from + // a prerelease. + return `${major}.${minor}.${patch + 1}`; + } + return `${major}.${minor}.${patch + 1}`; + } +} + +/** + * Resolve the input path to an actual SKILL.md file. + * - File path: must exist and be readable. + * - Directory path: must contain a SKILL.md. + */ +async function resolveSkillFile(inputPath: string): Promise { + if (!existsSync(inputPath)) { + throw new Error(`update: ${inputPath} does not exist`); + } + const st = await stat(inputPath); + if (st.isDirectory()) { + const candidate = join(inputPath, "SKILL.md"); + if (!existsSync(candidate)) { + throw new Error(`update: ${inputPath} does not contain a SKILL.md`); + } + return candidate; + } + return inputPath; +} + +/** + * Find the inclusive [start, end] line indices (0-indexed) of the + * frontmatter fence in `lines`, or `null` if there's no frontmatter. + * `start` is the line index of the opening `---`; `end` is the closing. + */ +function findFrontmatterBounds(lines: string[]): { start: number; end: number } | null { + if (lines.length === 0 || lines[0].trimEnd() !== "---") return null; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trimEnd() === "---") return { start: 0, end: i }; + } + return null; +} + +/** + * Edit the frontmatter block in-place: replace the `version:` line, or + * insert one immediately after the opening `---` if absent. Returns the + * new full file text. Leaves the body bytes byte-for-byte identical. + */ +function rewriteVersionLine(raw: string, newVersion: string): string { + // Preserve the original newline style on a best-effort basis. We split + // on \n (Node's universal), then re-join on \n. CRLF inputs would lose + // their carriage returns through this path — SKILL.md is markdown, so + // we accept that tradeoff. Round-trip tests below assert LF behaviour. + const lines = raw.split("\n"); + const bounds = findFrontmatterBounds(lines); + if (!bounds) { + // No frontmatter — nothing to update. Caller validates before we get + // here, but defend anyway. + throw new Error("update: file has no frontmatter block"); + } + // Look for an existing `version:` line inside the frontmatter (between + // the two `---` fences). YAML keys are case-sensitive and unquoted by + // convention here; we match `\s*version\s*:`. + const versionRe = /^(\s*)version(\s*):(\s*)(.*)$/; + for (let i = bounds.start + 1; i < bounds.end; i++) { + const m = lines[i].match(versionRe); + if (m) { + // Preserve leading whitespace and the spacing around the colon — + // only the value changes. We don't preserve quoting: if the user + // wrote `version: "1.2.3"`, we emit `version: 1.2.4` (the schema + // doesn't require quotes, and gray-matter parses either form + // identically). This is acceptable because (a) `tags` etc. are + // unaffected and (b) the version line is the only line we own. + lines[i] = `${m[1]}version${m[2]}:${m[3] || " "}${newVersion}`; + return lines.join("\n"); + } + } + // No existing version line: insert one right after the opening fence. + // Keep it near the top so it sits next to `name:`/`description:`. + lines.splice(bounds.start + 1, 0, `version: ${newVersion}`); + return lines.join("\n"); +} + +/** + * Bump (or set) the `version:` field of a SKILL.md and write it back. + * Returns the resolved path plus old/new versions. + * + * @throws when both / neither of `bump` and `newVersion` are supplied, + * when the path doesn't resolve to a SKILL.md, when the resulting + * version isn't valid semver, or when the resulting frontmatter + * fails schema validation. + */ +export async function updateSkillVersion(opts: UpdateOptions): Promise { + // Mutual exclusion: exactly one of bump | newVersion. + const hasBump = opts.bump !== undefined; + const hasExplicit = opts.newVersion !== undefined; + if (hasBump && hasExplicit) { + throw new Error("update: pass exactly one of --bump or --new-version, not both"); + } + if (!hasBump && !hasExplicit) { + throw new Error("update: pass one of --bump or --new-version "); + } + + const file = await resolveSkillFile(opts.path); + const raw = await readFile(file, "utf8"); + const parsed = matter(raw); + + // Determine the current version. Per the schema, a missing version + // defaults to "0.0.1" — we use that as the baseline so a `patch` from a + // freshly-scaffolded skill produces 0.0.2, which is what users expect. + const rawData = parsed.data ?? {}; + const currentVersion = + typeof rawData.version === "string" && rawData.version.length > 0 + ? rawData.version + : SCHEMA_DEFAULT_VERSION; + + // Compute the next version. + let next: string; + if (hasExplicit) { + if (!SEMVER_RE.test(opts.newVersion as string)) { + throw new Error( + `update: "${opts.newVersion}" is not valid semver (expected e.g. 1.2.3 or 1.2.3-beta)`, + ); + } + next = opts.newVersion as string; + } else { + next = bumpVersion(currentVersion, opts.bump as BumpKind); + } + + // Pre-validate by constructing the proposed frontmatter object and + // running the schema over it. We do this BEFORE touching disk so a + // schema failure never leaves the file half-written. + const proposedFrontmatter = { ...rawData, version: next }; + const schemaResult = SkillFrontmatterSchema.safeParse(proposedFrontmatter); + if (!schemaResult.success) { + const detail = schemaResult.error.issues + .map((i) => `${i.path.join(".") || ""}: ${i.message}`) + .join("; "); + throw new Error(`update: refusing to write — resulting frontmatter is invalid (${detail})`); + } + + if (opts.dryRun) { + return { path: file, oldVersion: currentVersion, newVersion: next, dryRun: true }; + } + + // Line-surgical write: only the `version:` line changes. Body bytes, + // field order, other YAML formatting all preserved. + const nextRaw = rewriteVersionLine(raw, next); + await writeFile(file, nextRaw, "utf8"); + + return { path: file, oldVersion: currentVersion, newVersion: next, dryRun: false }; +}