diff --git a/plugins/compound-engineering/skills/ce-update/SKILL.md b/plugins/compound-engineering/skills/ce-update/SKILL.md index 965fa72..f94ddd1 100644 --- a/plugins/compound-engineering/skills/ce-update/SKILL.md +++ b/plugins/compound-engineering/skills/ce-update/SKILL.md @@ -13,8 +13,9 @@ ce_platforms: [claude] # Check Plugin Version -Verify the installed compound-engineering plugin version matches the latest -release, and recommend the update command if it doesn't. Claude Code only. +Verify the installed compound-engineering plugin version matches the upstream +`plugin.json` on `main`, and recommend the update command if it doesn't. +Claude Code only. ## Pre-resolved context @@ -28,11 +29,17 @@ at skill-load time. For a marketplace-cached install it looks like `~/.claude/plugins/cache//compound-engineering//skills/ce-update`, so the currently-loaded version is the basename two `dirname` levels up. +The upstream version comes from `plugins/compound-engineering/.claude-plugin/plugin.json` +on `main` rather than the latest GitHub release tag, because the marketplace +installs plugin contents from `main` HEAD. Comparing against release tags +false-positives whenever `main` is ahead of the last tag (the normal state +between releases). + **Skill directory:** !`echo "${CLAUDE_SKILL_DIR}"` -**Latest released version:** -!`gh release list --repo EveryInc/compound-engineering-plugin --limit 30 --json tagName --jq '[.[] | select(.tagName | startswith("compound-engineering-v"))][0].tagName | sub("compound-engineering-v";"")' 2>/dev/null || echo '__CE_UPDATE_VERSION_FAILED__'` +**Latest upstream version:** +!`version=$(gh api repos/EveryInc/compound-engineering-plugin/contents/plugins/compound-engineering/.claude-plugin/plugin.json --jq '.content | @base64d | fromjson | .version' 2>/dev/null) && [ -n "$version" ] && echo "$version" || echo '__CE_UPDATE_VERSION_FAILED__'` **Currently loaded version:** !`case "${CLAUDE_SKILL_DIR}" in */plugins/cache/*/compound-engineering/*/skills/ce-update) basename "$(dirname "$(dirname "${CLAUDE_SKILL_DIR}")")" ;; *) echo '__CE_UPDATE_NOT_MARKETPLACE__' ;; esac` @@ -49,8 +56,8 @@ requires Claude Code and stop. No further action. ### 2. Handle failure cases -If **Latest released version** contains `__CE_UPDATE_VERSION_FAILED__`: tell -the user the latest release could not be fetched (gh may be unavailable or +If **Latest upstream version** contains `__CE_UPDATE_VERSION_FAILED__`: tell +the user the upstream version could not be fetched (gh may be unavailable or rate-limited) and stop. If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this @@ -68,13 +75,13 @@ Then stop. ### 3. Compare versions -**Up to date** — `{currently loaded} == {latest}`: +**Up to date** — `{currently loaded} == {latest upstream}`: > "compound-engineering **v{version}** is installed and up to date." -**Out of date** — `{currently loaded} != {latest}`: +**Out of date** — `{currently loaded} != {latest upstream}`: -> "compound-engineering is on **v{currently loaded}** but **v{latest}** is available. +> "compound-engineering is on **v{currently loaded}** but **v{latest upstream}** is available. > > Update with: > ``` diff --git a/tests/skills/ce-update.test.ts b/tests/skills/ce-update.test.ts index 179713e..9cb77f9 100644 --- a/tests/skills/ce-update.test.ts +++ b/tests/skills/ce-update.test.ts @@ -1,32 +1,165 @@ -import { readFileSync } from "fs" +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" +import { execFileSync } from "child_process" +import { tmpdir } from "os" import path from "path" import { describe, expect, test } from "bun:test" -// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556. -// -// `CLAUDE_PLUGIN_ROOT` is set by the Claude Code harness to the currently-loaded -// plugin version directory (e.g. `~/.claude/plugins/cache//compound-engineering/`). -// It is NOT the plugins cache root. Appending `/cache//compound-engineering/` -// onto it produces a path that never exists, which causes the cache-probe to fail -// and emit the `__CE_UPDATE_CACHE_FAILED__` sentinel on every healthy install. -// -// This has regressed twice. Fail fast if the antipattern reappears. +const SKILL_PATH = path.join( + process.cwd(), + "plugins/compound-engineering/skills/ce-update/SKILL.md", +) +const SKILL_BODY = readFileSync(SKILL_PATH, "utf8") describe("ce-update SKILL.md", () => { - const skillPath = path.join( - process.cwd(), - "plugins/compound-engineering/skills/ce-update/SKILL.md", - ) - const body = readFileSync(skillPath, "utf8") - + // Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556. + // + // `CLAUDE_PLUGIN_ROOT` points at the currently-loaded plugin version directory + // (e.g. `~/.claude/plugins/cache//compound-engineering/`), + // NOT the plugins cache root. Appending `/cache//compound-engineering/` + // produces a path that never exists, which caused the cache-probe to fail and + // emit `__CE_UPDATE_CACHE_FAILED__` on every healthy install. Has regressed twice. test("does not append a /cache// suffix onto CLAUDE_PLUGIN_ROOT", () => { - // Matches any `${CLAUDE_PLUGIN_ROOT}/cache//` or `${CLAUDE_PLUGIN_ROOT}/cache/"` - // pattern. The harness variable is already under `cache//`, - // so concatenating another `cache/...` segment is always wrong. const antiPattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/cache\// expect( - antiPattern.test(body), + antiPattern.test(SKILL_BODY), "ce-update/SKILL.md reintroduced the ${CLAUDE_PLUGIN_ROOT}/cache/... antipattern — derive the cache dir from dirname \"${CLAUDE_PLUGIN_ROOT}\" instead.", ).toBe(false) }) }) + +// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/659. +// +// The marketplace installs plugin contents from `main` HEAD, so the cache +// folder basename reflects `plugin.json` at install time — not any release tag. +// Comparing the installed folder against the latest GitHub release tag caused +// a persistent false-positive "Out of date" whenever `main` was ahead of the +// last tag (the normal state between releases), and the prescribed fix +// (`claude plugin update ...`) reinstalled the same version, looping forever. +// +// Rather than grep-testing the literal shell string, this suite extracts the +// "Latest upstream version" pre-resolution command and executes it against a +// mocked `gh` that returns distinguishable values for `gh api` vs +// `gh release list`. The command must report the version from `plugin.json`, +// not from release tags. +describe("ce-update 'Latest upstream version' pre-resolution command", () => { + test("declares a 'Latest upstream version' pre-resolution section", () => { + // Fails loudly if the SKILL structure regresses (renamed section, removed + // pre-resolution block) so behavioral tests below can rely on it. + expect(SKILL_BODY).toMatch(/\*\*Latest upstream version:\*\*\s*\n!`[^`\n]+`/) + }) + + test("returns the version from main's plugin.json, not any release tag", () => { + // Chosen so a tag-based fallback would produce a clearly different value + // than the plugin.json-based read. Either 1.0.0 or an empty/sentinel + // output indicates the command is reading the wrong source. + const pluginJsonVersion = "99.0.0" + const releaseTagVersion = "1.0.0" + + const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), { + pluginJsonVersion, + releaseTagVersion, + }) + + expect(stdout).toBe(pluginJsonVersion) + }) + + test("emits __CE_UPDATE_VERSION_FAILED__ when upstream plugin.json cannot be read", () => { + // Simulates gh failing entirely (missing auth, offline, rate-limited). + // The fallback must produce the sentinel so the skill's decision logic + // can stop rather than silently compare against an empty string — a + // pipeline-style `|| echo` only catches last-stage failures, and jq on + // empty input exits 0 with no output. + const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), { + ghExitCode: 1, + }) + expect(stdout).toContain("__CE_UPDATE_VERSION_FAILED__") + }) +}) + +/** + * Extract the shell command between `**Latest upstream version:**` and the + * next blank line in SKILL.md. The command sits on the next line wrapped in + * backticks with a leading `!` (Claude Code's pre-resolution syntax). + */ +function extractUpstreamVersionCommand(body: string): string { + const match = body.match(/\*\*Latest upstream version:\*\*\s*\n!`([^`\n]+)`/) + if (!match) { + throw new Error( + `Could not extract 'Latest upstream version' pre-resolution command from ${SKILL_PATH}`, + ) + } + return match[1] +} + +type MockOptions = { + pluginJsonVersion?: string + releaseTagVersion?: string + ghExitCode?: number +} + +/** + * Run the skill's upstream-version command with a mocked `gh` on PATH. + * The mock emits distinct payloads for `gh api` vs `gh release list` so the + * test can prove which source the command actually reads from. + */ +function runUpstreamCommand(command: string, options: MockOptions): string { + const { pluginJsonVersion, releaseTagVersion, ghExitCode } = options + const mockDir = mkdtempSync(path.join(tmpdir(), "ce-update-gh-")) + try { + const pluginJsonB64 = pluginJsonVersion + ? Buffer.from( + JSON.stringify({ name: "compound-engineering", version: pluginJsonVersion }), + ).toString("base64") + : "" + const releaseJson = releaseTagVersion + ? JSON.stringify([{ tagName: `compound-engineering-v${releaseTagVersion}` }]) + : "[]" + + // Emulate gh's behaviour without requiring host `jq`: real `gh --jq` uses + // gojq embedded in the binary, so neither the skill nor this mock needs + // an external jq on PATH. When the skill asks a `--jq` filter that + // extracts `.version`, we emit the pre-computed plugin.json version; when + // it asks for `.tagName`, we emit the pre-computed release tag. Any other + // filter is unexpected and the mock fails loudly so the test doesn't pass + // by accident. + const ghScript = `#!/bin/bash +${ghExitCode !== undefined ? `exit ${ghExitCode}` : ` +subcommand="$1"; shift +jq_filter="" +while [ $# -gt 0 ]; do + case "$1" in + --jq) jq_filter="$2"; shift 2 ;; + *) shift ;; + esac +done +case "$subcommand" in + api) + case "$jq_filter" in + *'.version'*) printf '%s\\n' '${pluginJsonVersion ?? ""}' ;; + '') printf '%s\\n' '{"content":"${pluginJsonB64}"}' ;; + *) echo "unexpected --jq filter for gh api: $jq_filter" >&2; exit 2 ;; + esac + ;; + release) + # If the skill ever falls back to release-tag lookup, this is what it gets. + case "$jq_filter" in + *'tagName'*) printf '%s\\n' '${releaseTagVersion ?? ""}' ;; + '') printf '%s\\n' '${releaseJson}' ;; + *) echo "unexpected --jq filter for gh release: $jq_filter" >&2; exit 2 ;; + esac + ;; + *) exit 1 ;; +esac +`}` + const ghPath = path.join(mockDir, "gh") + writeFileSync(ghPath, ghScript) + chmodSync(ghPath, 0o755) + + return execFileSync("bash", ["-c", command], { + env: { ...process.env, PATH: `${mockDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }).trim() + } finally { + rmSync(mockDir, { recursive: true, force: true }) + } +}