diff --git a/extensions/skills/CHANGELOG.md b/extensions/skills/CHANGELOG.md index fe3b3c85cb5..f88b543a1f6 100644 --- a/extensions/skills/CHANGELOG.md +++ b/extensions/skills/CHANGELOG.md @@ -1,4 +1,9 @@ -# Changelog +# Skills Changelog + +## [Fix Silent Auto-Update on Load] - 2026-04-21 + +- Stop silently auto-updating outdated skills when opening Manage Skills +- Fix the orange "Update available" highlight that stopped appearing for outdated skills ## [Show Installed Badge in Search Results] - 2026-04-20 diff --git a/extensions/skills/src/utils/skills-cli.ts b/extensions/skills/src/utils/skills-cli.ts index 60b4ba7af44..908d0d2c180 100644 --- a/extensions/skills/src/utils/skills-cli.ts +++ b/extensions/skills/src/utils/skills-cli.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -import { getCustomNpxPath } from "../preferences"; +import { getCustomNpxPath, preferences } from "../preferences"; import type { InstalledSkill, Skill, SkillLockEntry } from "../shared"; import { execAsync } from "./exec-async"; import { getExecOptions } from "./exec-options"; @@ -39,17 +39,6 @@ async function runSkillsCli(args: string[]): Promise { } } -// eslint-disable-next-line no-control-regex -const ANSI_REGEX = /\x1B\[[0-9;]*m/g; - -/** - * Strip ANSI escape codes from CLI output. - * Used by checkForUpdates() which does not have a --json option. - */ -function stripAnsi(str: string): string { - return str.replace(ANSI_REGEX, ""); -} - /** Escape a value for safe use as a shell argument. */ function shellEscape(arg: string): string { if (isWindows) { @@ -233,17 +222,70 @@ export async function removeSkill(skillName: string, agentDisplayNames?: string[ await runSkillsCli(args); } +interface GitHubTreeResponse { + sha: string; + tree: Array<{ path: string; sha: string; type: string }>; + truncated?: boolean; +} + +async function fetchRepoTree(source: string, token: string | undefined): Promise { + const [owner, repo] = source.split("/"); + if (!owner || !repo) return null; + + const headers: Record = { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + for (const branch of ["main", "master"]) { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, { + headers, + }); + if (res.ok) { + const data = (await res.json()) as GitHubTreeResponse; + if (data.truncated) return null; + return data; + } + if (res.status === 403 || res.status === 429) return null; + } + return null; +} + /** - * Check for available skill updates. - * Parses `npx -y skills@latest check` output for "↑ skillName" lines. + * Implemented against the GitHub Trees API rather than `npx skills check` because + * the CLI's check command reinstalls outdated skills as a side effect since v1.5.0. */ export async function checkForUpdates(): Promise { - const stdout = await runSkillsCli(["check"]); - return stripAnsi(stdout) - .split("\n") - .map((line) => line.match(/↑\s+(\S+)/)) - .filter((m): m is RegExpMatchArray => m !== null) - .map((m) => m[1]); + const lock = await readSkillLock(); + const entries = Object.entries(lock).filter(([, e]) => e.sourceType === "github" && e.skillFolderHash && e.skillPath); + if (entries.length === 0) return []; + + const byRepo = new Map>(); + for (const [name, entry] of entries) { + const list = byRepo.get(entry.source) ?? []; + list.push({ name, skillPath: entry.skillPath, expectedHash: entry.skillFolderHash }); + byRepo.set(entry.source, list); + } + + const { githubToken } = preferences; + + const results = await Promise.all( + [...byRepo.entries()].map(async ([source, skills]) => { + try { + const tree = await fetchRepoTree(source, githubToken); + if (!tree) return []; + const { sha: rootSha, tree: entries } = tree; + return skills.flatMap((skill) => { + const folder = skill.skillPath.replace(/\/?SKILL\.md$/, ""); + const upstreamSha = folder ? entries.find((t) => t.path === folder && t.type === "tree")?.sha : rootSha; + return upstreamSha && upstreamSha !== skill.expectedHash ? [skill.name] : []; + }); + } catch { + return []; + } + }), + ); + return results.flat(); } /**