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
7 changes: 6 additions & 1 deletion extensions/skills/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
82 changes: 62 additions & 20 deletions extensions/skills/src/utils/skills-cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,17 +39,6 @@ async function runSkillsCli(args: string[]): Promise<string> {
}
}

// 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) {
Expand Down Expand Up @@ -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<GitHubTreeResponse | null> {
const [owner, repo] = source.split("/");
if (!owner || !repo) return null;

const headers: Record<string, string> = {
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<string[]> {
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<string, Array<{ name: string; skillPath: string; expectedHash: string }>>();
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();
}

/**
Expand Down